Benchmarks
What is a benchmark?
Section titled “What is a benchmark?”A benchmark measures how long a piece of code takes to run. Go benchmarks live in _test.go files alongside unit tests and are run by the same go test command — just with different flags.
A benchmark function has this signature:
func BenchmarkXxx(b *testing.B) { // ... setup code (not measured) ... for b.Loop() { // Go 1.24+ preferred form // code under measurement }}The testing.B value drives the loop. The framework starts with a small value for b.N and keeps increasing it until the benchmark runs long enough to produce a stable measurement. You never set b.N yourself.
Two loop forms
Section titled “Two loop forms”Go 1.24 introduced b.Loop() as the preferred alternative to the classic b.N counter loop:
// Classic form — works in all Go versionsfunc BenchmarkFibClassic(b *testing.B) { for i := 0; i < b.N; i++ { Fibonacci(20) }}
// Modern form — Go 1.24+func BenchmarkFib(b *testing.B) { for b.Loop() { Fibonacci(20) }}Both forms are correct. b.Loop() is preferred in Go 1.24+ because it handles timer reset internally and is less error-prone; since Go 1.26 it no longer blocks inlining of the benchmarked call, so there is no performance reason left to keep b.N, and it remains the recommended form in Go 1.27.
Setup outside the loop
Section titled “Setup outside the loop”If your benchmark needs expensive setup (opening a file, building a large slice), do it before the loop. With the classic b.N form you must call b.ResetTimer() yourself so setup time is not counted:
// Classic b.N form — needs an explicit ResetTimerfunc BenchmarkIsPrimeListClassic(b *testing.B) { numbers := make([]int, 1000) for i := range numbers { numbers[i] = i + 2 } b.ResetTimer() // start measuring from here
for i := 0; i < b.N; i++ { for _, n := range numbers { IsPrime(n) } }}b.Loop() resets the timer itself the first time it is called (go doc testing.B.Loop), so the equivalent modern form skips b.ResetTimer() entirely:
func BenchmarkIsPrimeList(b *testing.B) { numbers := make([]int, 1000) for i := range numbers { numbers[i] = i + 2 }
for b.Loop() { for _, n := range numbers { IsPrime(n) } }}b.Loop also keeps the loop’s results alive, so the compiler cannot optimise the measured work away — this is the main reason to prefer it over the b.N form, not just the automatic timer reset.
Why b.Loop prevents dead-code elimination, and the sink idiom for b.N
Section titled “Why b.Loop prevents dead-code elimination, and the sink idiom for b.N”The compiler is allowed to delete any computation whose result is never used. A benchmark body that computes something and throws the result away is exactly that kind of dead code — and an optimizing compiler that got smart enough to prove Fibonacci(20) is called with a constant and never observed could, in principle, skip calling it at all inside the loop. go doc testing.B.Loop documents the fix built into the modern form:
Within the body of a "for b.Loop() { ... }" loop, arguments to and resultsfrom function calls and assigned variables within the loop are kept alive,preventing the compiler from fully optimizing away the loop body. Currently,this is implemented as a compiler transformation that wraps such variableswith a runtime.KeepAlive intrinsic call.The classic b.N form gets none of this protection, so the idiomatic defense is the sink idiom: assign the result to a package-level variable so the compiler can never prove it is unobserved.
var sink int
// The sink idiom for the classic b.N form: assigning to a package-level// variable prevents the compiler from proving the result is unused.func BenchmarkFibSink(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = Fibonacci(20) } sink = r}Verified locally — both forms measure the same function at comparable cost, confirming the sink is doing its job without adding meaningful overhead:
BenchmarkFibSink-11 183465904 5.967 ns/op 0 B/op 0 allocs/opBenchmarkFibLoop-11 185444034 6.493 ns/op 0 B/op 0 allocs/opA b.N benchmark that assigns its result to a local variable and never uses it again has no such protection — the compiler is free to remove the call it thinks nobody observes.
Running benchmarks
Section titled “Running benchmarks”Unit tests do not run benchmarks by default. Use the -bench flag:
# Run all benchmarks, show memory allocationsgo test -bench=. -benchmem ./...
# Run only benchmarks matching a name patterngo test -bench=BenchmarkFib -benchmem ./...
# Run benchmarks for at least 5 seconds per functiongo test -bench=. -benchtime=5s ./...Sub-benchmarks for size sweeps
Section titled “Sub-benchmarks for size sweeps”b.Run works inside a benchmark exactly like it does inside a test: it groups related measurements under one parent and gives each a name in the output. The most common use is sweeping an input size to see how cost scales:
func BenchmarkFibSizes(b *testing.B) { for _, n := range []int{10, 20, 30} { b.Run(name(n), func(b *testing.B) { for b.Loop() { sink = Fibonacci(n) } }) }}Real output — note the cost climbing with n, exactly as expected for this iterative Fibonacci:
BenchmarkFibSizes/n=10-11 309100298 3.922 ns/opBenchmarkFibSizes/n=20-11 188798964 6.398 ns/opBenchmarkFibSizes/n=30-11 126663669 9.526 ns/op-bench 'FibSizes/n=20' runs only that one size — the same Parent/sub filtering rule as t.Run.
Reading the output
Section titled “Reading the output”Measured locally with go test -bench=. -benchmem (Go 1.26.5, Apple M3 Pro) against the Fibonacci(20) benchmarks above:
BenchmarkFibClassic-11 60018 19425 ns/op 0 B/op 0 allocs/opBenchmarkFib-11 58771 20169 ns/op 0 B/op 0 allocs/op| Column | Meaning |
|---|---|
-11 suffix | GOMAXPROCS value (logical CPUs on the machine that ran it — yours will differ) |
60018 | Number of iterations the benchmark ran |
19425 ns/op | Nanoseconds per operation (also machine-dependent — do not compare across hardware) |
0 B/op | Bytes allocated per operation (needs -benchmem) |
0 allocs/op | Heap allocations per operation (needs -benchmem) |
Fibonacci allocates nothing because it uses only stack variables — that is why both alloc columns are zero on both rows. A function that builds a []string would show non-zero values.
b.ReportAllocs, ReportMetric, and SetBytes
Section titled “b.ReportAllocs, ReportMetric, and SetBytes”-benchmem on the command line turns on allocation reporting for every benchmark in the run; b.ReportAllocs() turns it on for one specific benchmark function regardless of the flag — useful when only a few benchmarks in a large suite care about allocations. b.SetBytes(n) adds a throughput column (MB/s) by declaring how many bytes one iteration processed. b.ReportMetric(value, "unit/op") adds a completely custom column for a domain-specific measurement.
func BenchmarkBuildString(b *testing.B) { b.ReportAllocs() var out string for b.Loop() { var sb strings.Builder for i := 0; i < 100; i++ { sb.WriteString("x") } out = sb.String() } b.SetBytes(int64(len(out)))}
func BenchmarkWithMetric(b *testing.B) { var n int for b.Loop() { n += Fibonacci(10) } b.ReportMetric(float64(n)/float64(b.N), "fib-sum/op")}Real output:
BenchmarkBuildString-11 5196698 212.0 ns/op 471.78 MB/s 248 B/op 5 allocs/opBenchmarkWithMetric-11 337696298 3.497 ns/op 55.00 fib-sum/op 0 B/op 0 allocs/op248 B/op with 5 allocs/op for a 100-byte string is exactly the kind of allocation count you would not have guessed from reading the code — strings.Builder grows its internal buffer geometrically, so building a 100-byte string costs a handful of reallocations, not one allocation per WriteString call.
Comparing runs with benchstat
Section titled “Comparing runs with benchstat”A single benchmark run is noisy — GC pauses, CPU frequency scaling, and background processes all add variance. benchstat (golang.org/x/perf/cmd/benchstat) takes two or more go test -bench outputs and computes whether a difference is real or just noise, with a p-value.
go test -bench BenchmarkFibLoop -count=10 . > old.txt# ... change the implementation ...go test -bench BenchmarkFibLoop -count=10 . > new.txtbenchstat old.txt new.txtReal comparison between two genuinely different iterative Fibonacci implementations in this lesson’s scratch module, -count=10 each:
│ old.txt │ new.txt │ │ sec/op │ sec/op vs base │FibLoop-11 6.430n ± 2% 6.619n ± 1% +2.94% (p=0.000 n=10)p=0.000 (below the conventional 0.05 threshold) means benchstat is confident the +2.94% difference is real, not noise — -count=10 gives it enough samples per side to say so; a single run of each could easily have shown the opposite direction by chance.
Profiling with -cpuprofile and go tool pprof
Section titled “Profiling with -cpuprofile and go tool pprof”When a benchmark result says something is slow but not what, capture a CPU profile and inspect it:
go test -bench BenchmarkFibLoop -cpuprofile=cpu.prof .go tool pprof -top cpu.profReal output, top functions by self time (flat):
Duration: 1.32s, Total samples = 1150ms (86.91%) flat flat% sum% cum cum% 940ms 81.74% 81.74% 960ms 83.48% example.com/bm.Fibonacci (inline) 120ms 10.43% 92.17% 120ms 10.43% testing.(*B).Loop (inline) 70ms 6.09% 98.26% 1150ms 100% example.com/bm.BenchmarkFibLoop 20ms 1.74% 100% 20ms 1.74% runtime.asyncPreemptflat is time spent in the function itself; cum (cumulative) includes everything it called. go tool pprof -http=:8080 cpu.prof (Go 1.26+ opens directly to the flame-graph view) gives an interactive, zoomable view of the same data in a browser — far easier to navigate than the text table once a profile has more than a handful of hot functions.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
go test -bench | Measures performance with a built-in tool | Needs multiple runs — watch for noise from GC and CPU throttling |
b.N auto-calibration | The framework picks an iteration count that gives a stable result | Setup left inside the loop gets measured too; needs the sink idiom to resist dead-code elimination |
b.ReportAllocs() | Shows heap allocations per operation clearly | You need to understand escape analysis to interpret the result |
benchstat with -count=10 | Turns “it feels faster” into a p-value | Takes 10x as long as a single run per side |
| pprof profiling | Deep CPU/memory hotspot analysis | Profiling overhead — do not leave it running in production by accident |
Common Misconceptions
Section titled “Common Misconceptions”- A benchmark result on your dev machine matches production — CPU throttling, GC state, and background processes differ between machines.
b.Nis a count you choose — Go calibratesb.Nautomatically so the benchmark runs for about a second — never setb.Nyourself.allocs/op = 0always means optimal — zero allocations is good, but if CPU work went up instead, the result may not actually be better.- Benchmarks are only for micro-optimization — benchmarks can also validate architectural decisions, e.g. channel vs mutex, slice vs map.
- A single go test -bench run is enough to say “X is faster than Y” — one run cannot separate a real effect from machine noise; that is exactly what
benchstatwith-count=10(or more) is for. - The classic
for i := 0; i < b.N; i++form is automatically safe from dead-code elimination likeb.Loop()— it is not; without the sink idiom, an aggressive compiler can legally discard a call whose result is only assigned to an unused local.
💡 Real-world example
Go standard library —
encoding/json/bench_test.go(open at$(go env GOROOT)/src/encoding/json/bench_test.go) benchmarks encoding and decoding across multiple representative payloads, and thestrconvpackage’s own benchmarks live instrconv/quote_test.go— both are ordinary_test.gofiles using exactly the patterns in this lesson.
Benchmark calibration and measurement
Section titled “Benchmark calibration and measurement”flowchart TD
A["go test -bench=Name"] --> B["start with a small b.N / call b.Loop()"]
B --> C["run the benchmarked code"]
C --> D{"ran long enough for a stable measurement?"}
D -->|no| E["increase b.N, or b.Loop() keeps returning true"]
E --> C
D -->|yes| F["report ns/op, B/op, allocs/op"] Reading AI-generated code
Section titled “Reading AI-generated code”Generated benchmarks tend to compile and produce a number — which is exactly the problem, since a plausible-looking number is not the same as a meaningful one. Check for these:
- Is expensive setup (building a slice, opening a file, formatting a string) written inside the
for b.Loop()orfor i := 0; i < b.N; i++body, instead of before it? That inflates every measurement with work the benchmark did not intend to measure. - In a classic
b.Nbenchmark, is the result of the call ever assigned to a package-level variable (the sink idiom), returned, or otherwise observed — or does it just vanish into a local variable nothing reads afterward? - Does a
b.N-form benchmark callb.ResetTimer()after any setup, or does it forget it, silently including setup cost in every reported number? - Is
-benchmem(orb.ReportAllocs()) actually being used before a generated summary claims a change made something “more efficient”? A CPU-only comparison hides a regression that traded CPU time for extra allocations. - Does the benchmark run on a busy dev machine — VS Code, a browser, a running server — and the answer treat the resulting
ns/opas a precise, comparable-across-machines number?
🐛 Spot the bug
func BenchmarkParseConfig(b *testing.B) { for i := 0; i < b.N; i++ { data := loadTestFixture() // reads a file from disk every iteration ParseConfig(data) }}Show the answer
loadTestFixture() reads a file from disk on every single iteration, so the benchmark measures disk I/O plus parsing, not ParseConfig alone — and because the classic b.N form was used with no b.ResetTimer(), there is no setup phase to move it into anyway. The fix restructures the benchmark: load the fixture data once before the loop, and pass the already-loaded bytes to ParseConfig inside it — for b.Loop() { ParseConfig(data) } with data := loadTestFixture() above the loop. As written, this benchmark will report a number, and it will look meaningful, but it answers “how fast is disk I/O plus parsing” rather than the question it claims to answer.