Skip to content

Benchmarks

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.

Go 1.24 introduced b.Loop() as the preferred alternative to the classic b.N counter loop:

// Classic form — works in all Go versions
func 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.

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 ResetTimer
func 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 results
from 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 variables
with 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/op
BenchmarkFibLoop-11 185444034 6.493 ns/op 0 B/op 0 allocs/op

A 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.

Unit tests do not run benchmarks by default. Use the -bench flag:

Terminal window
# Run all benchmarks, show memory allocations
go test -bench=. -benchmem ./...
# Run only benchmarks matching a name pattern
go test -bench=BenchmarkFib -benchmem ./...
# Run benchmarks for at least 5 seconds per function
go test -bench=. -benchtime=5s ./...

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/op
BenchmarkFibSizes/n=20-11 188798964 6.398 ns/op
BenchmarkFibSizes/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.

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/op
BenchmarkFib-11 58771 20169 ns/op 0 B/op 0 allocs/op
ColumnMeaning
-11 suffixGOMAXPROCS value (logical CPUs on the machine that ran it — yours will differ)
60018Number of iterations the benchmark ran
19425 ns/opNanoseconds per operation (also machine-dependent — do not compare across hardware)
0 B/opBytes allocated per operation (needs -benchmem)
0 allocs/opHeap 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/op
BenchmarkWithMetric-11 337696298 3.497 ns/op 55.00 fib-sum/op 0 B/op 0 allocs/op

248 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.

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.

Terminal window
go test -bench BenchmarkFibLoop -count=10 . > old.txt
# ... change the implementation ...
go test -bench BenchmarkFibLoop -count=10 . > new.txt
benchstat old.txt new.txt

Real 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:

Terminal window
go test -bench BenchmarkFibLoop -cpuprofile=cpu.prof .
go tool pprof -top cpu.prof

Real 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.asyncPreempt

flat 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.

What you getBenefitCost
go test -benchMeasures performance with a built-in toolNeeds multiple runs — watch for noise from GC and CPU throttling
b.N auto-calibrationThe framework picks an iteration count that gives a stable resultSetup left inside the loop gets measured too; needs the sink idiom to resist dead-code elimination
b.ReportAllocs()Shows heap allocations per operation clearlyYou need to understand escape analysis to interpret the result
benchstat with -count=10Turns “it feels faster” into a p-valueTakes 10x as long as a single run per side
pprof profilingDeep CPU/memory hotspot analysisProfiling overhead — do not leave it running in production by accident
  • A benchmark result on your dev machine matches production — CPU throttling, GC state, and background processes differ between machines.
  • b.N is a count you choose — Go calibrates b.N automatically so the benchmark runs for about a second — never set b.N yourself.
  • allocs/op = 0 always 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 benchstat with -count=10 (or more) is for.
  • The classic for i := 0; i < b.N; i++ form is automatically safe from dead-code elimination like b.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 the strconv package’s own benchmarks live in strconv/quote_test.go — both are ordinary _test.go files using exactly the patterns in this lesson.

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"]
How go test calibrates a benchmark before reporting a result

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() or for 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.N benchmark, 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 call b.ResetTimer() after any setup, or does it forget it, silently including setup cost in every reported number?
  • Is -benchmem (or b.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/op as 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.

What does the testing framework do with b.N during a benchmark run?
Which flag makes go test report bytes allocated per operation?
Why is this benchmark's result untrustworthy compared to assigning to sink?
var sink int
func BenchmarkCompute(b *testing.B) {
    for i := 0; i < b.N; i++ {
        r := Compute(42)
        _ = r
    }
}
Two go test -bench runs before and after a code change show old=84.00ns and new=208.50ns with benchstat reporting p=0.010, n=10. What does the p-value tell you?
What is the preferred benchmark loop form in Go 1.24+?
What does this benchmark actually measure?
func BenchmarkSort(b *testing.B) {
    for b.Loop() {
        data := makeRandomSlice(10000) // fresh 10,000-element slice
        sort.Ints(data)
    }
}