Lesson 22 · Testing, tooling & performance

Benchmarks & pprof

Measure, don't guess — the tooling for making Go fast.

Your win: write and run a benchmark, read ns/op / allocs/op, and know how to pull a CPU/memory profile with pprof — the "how would you find a bottleneck?" answer.

Benchmarks are built in too

A benchmark is func BenchmarkXxx(b *testing.B) in a _test.go file. In Go 1.24+ (our 1.25) the robust form is for b.Loop(), which times only the loop and blocks misleading compiler optimisations:1

func BenchmarkRender(b *testing.B) {
    in := setup()          // runs once, not timed
    for b.Loop() {          // modern form (Go 1.24+)
        Render(in)
    }
}
// older form: for i := 0; i < b.N; i++ { ... }
$ go test -bench=Render -benchmem
BenchmarkRender-8   1.2M   980 ns/op   256 B/op   4 allocs/op
Read the three numbers ns/op — time per operation (speed). B/op — bytes allocated per op. allocs/op — heap allocations per op. Allocations are often the real cost: fewer allocs usually means faster and less GC pressure (Lesson 23). -benchmem turns the last two on.

Profiling with pprof

Benchmarks tell you how slow; a profile tells you where. Capture one, then explore it:2

$ go test -bench=Render -cpuprofile cpu.out -memprofile mem.out
$ go tool pprof cpu.out
(pprof) top      # hottest functions
(pprof) list Render   # line-by-line time inside a function
(pprof) web      # visual call graph

For a live server, importing net/http/pprof exposes /debug/pprof/ so you can profile production traffic: go tool pprof http://host/debug/pprof/profile.

Escape analysis — where allocations come from

Go decides whether a value lives on the stack (cheap, auto-freed) or escapes to the heap (costs an allocation + GC work). See its decisions with:

$ go build -gcflags=-m ./...
./x.go:12:9: &buf escapes to heap

Reducing heap escapes is the most common Go optimisation — and the reason allocs/op matters.3

Anchor — profiling vs production observability Profiling is for development: find the hot spot, optimise, re-benchmark. In production, our services lean on complementary signals — metrics (e.g. EmailMetrics) and tracing spans (interceptors.StartSpan, seen in every repo method). Different tools, same goal: know where time goes before you change anything.
The golden rule Measure, don't guess. Optimise only what a benchmark/profile proves is hot, then re-measure to confirm the win. Intuition about Go performance is wrong surprisingly often — a favourite interview theme.
Read this next

The Go Blog — "Profiling Go Programs" + testing.B.Loop

A real worked profiling session (10× speedup), plus the modern benchmark form. 100 Go Mistakes has an excellent benchmarking-pitfalls chapter.

go.dev/blog/pprof
go.dev/blog/testing-b-loop

Check yourself (from memory)

Q1. A Go benchmark function has the signature…

BenchmarkXxx(b *testing.B), looping with b.Loop() (or the older b.N).

Q2. go test -bench=. -benchmem additionally reports…

B/op and allocs/op — allocations, which often dominate cost and GC pressure.

Q3. You inspect a captured CPU profile with…

go tool pprof — then top, list, web to find the hot spot.
You suspect a function is slow. Outline the measure-first workflow.
recall, then click to reveal
(1) Write a benchmark: func BenchmarkFn(b *testing.B){ for b.Loop(){ Fn(in) } }. (2) Run go test -bench=BenchmarkFn -benchmem → read ns/op, B/op, allocs/op. (3) Profile: -cpuprofile cpu.out, then go tool pprof cpu.out (top/list/web) to find the hot spot. (4) For allocs, -memprofile + go build -gcflags=-m for escapes. Optimise the proven hot spot, then re-benchmark.
Want a real before/after: profile a small function, cut its allocations, and watch ns/op drop? Ask me — hands-on practice.

1. The Go Blog — More predictable benchmarking with testing.B.Loop.

2. The Go Blog — Profiling Go Programs.

3. 100 Go Mistakes — allocations & benchmarking.