Benchmarks measure how fast code runs and how much it allocates. The testing package runs them with the same go test tooling you already use for unit tests, so the workflow lives next to your tests instead of in a separate harness. This chapter covers the BenchmarkXxx function shape, the b.N loop and timer controls, the -bench, -benchtime, and -benchmem flags, and the common traps that produce misleading numbers.
A benchmark is a function with the signature func BenchmarkXxx(b *testing.B) placed in a _test.go file. The Go test runner finds anything starting with Benchmark the same way it finds anything starting with Test. The body must run the work being measured exactly b.N times in a loop:
Run it with -bench. The flag takes a regex, and . matches every benchmark in the package:
The number after the dash on BenchmarkCartTotal-10 is GOMAXPROCS, not a meaningful part of the result. 185437288 is the chosen value of b.N, and 6.464 ns/op is the average time per iteration. The runner picks b.N itself: it starts at 1, runs, sees how long that took, and scales up until the total run hits the benchmark time budget (1 second by default).
Cost: A benchmark with very fast work (single-digit nanoseconds) needs hundreds of millions of iterations to fill the time budget, so even "instant" benchmarks take about a second to run. The reported ns/op is an average over the full window, not a single-call measurement.
The test runner sets b.N based on how long one iteration takes. For fast code, b.N ends up in the hundreds of millions; for slow code, it might be 10 or 100. Your benchmark must work correctly for any value of b.N, including 1.
The loop runs b.N times regardless of value. That's the only contract. If you write something like for i := 0; i < 1000; i++ and ignore b.N, the runner has no way to scale the work, the reported ns/op becomes meaningless, and the benchmark wastes the time budget on a fixed amount of work.
Setup work that shouldn't count toward the measurement runs before the b.N loop. If that setup is expensive, call b.ResetTimer() after it so the elapsed time and allocations from setup don't pollute the result.
Without b.ResetTimer(), the time spent filling the 10,000-element cart would be included in the first iteration's wall clock and counted as part of the measurement. With it, the clock starts fresh right before the loop.
For setup that happens inside the loop (per-iteration), b.StopTimer() and b.StartTimer() carve out a window the runner ignores:
The clock pauses during generateCart, runs during the summation, and pauses again on the next iteration. This pattern is heavier than ResetTimer because pausing and restarting the timer has its own overhead, so only use it when each iteration genuinely needs fresh state.
Cost: b.StopTimer and b.StartTimer add a few hundred nanoseconds of overhead per call. For benchmarks measuring sub-microsecond work, this can dominate the result. Prefer b.ResetTimer and a single setup block when possible.
The point of a benchmark is usually to decide which of two implementations is faster. String building from order items is a classic case: repeated += allocates a new string every iteration, while strings.Builder writes into a single growing buffer.
Run both with -bench and -benchmem:
The builder is about twice as fast and allocates a third of the memory. The B/op (bytes allocated per operation) and allocs/op (number of heap allocations per operation) columns appear because -benchmem was passed. Without -benchmem, only ns/op shows.
The columns are:
| Column | Meaning |
|---|---|
ns/op | Average wall-clock time per iteration, in nanoseconds |
B/op | Average bytes allocated per iteration on the heap |
allocs/op | Average number of heap allocations per iteration |
Watch for B/op and allocs/op even more than ns/op. Allocations cause garbage collector work that may not show up in this benchmark but will show up in production under load.
If you want the allocation columns to always appear for a specific benchmark regardless of whether the runner is invoked with -benchmem, call b.ReportAllocs() at the top of the function:
Now go test -bench=ReceiptBuilder prints the allocation columns even without -benchmem. This is useful for benchmarks where allocation count is the main thing you're measuring; you don't want a teammate to forget the flag and miss the most important number.
The default time budget is 1 second per benchmark. Override it with -benchtime:
The 5-second window gives b.N more room to grow, which produces a more stable average. For noisy benchmarks (anything involving I/O, locks, or GC interaction), a longer window smooths out variance.
You can also pin the iteration count directly with the Nx form:
1000000x means "run exactly 1,000,000 iterations regardless of time". This is useful when comparing runs across machines or commits, because the number of operations is fixed and only the time varies. It's also useful for very slow benchmarks where the default 1-second budget would only give you 2 or 3 iterations.
Cost: A longer -benchtime doesn't make individual ops faster; it makes the average more accurate. If you only care about whether implementation A is faster than B, a 3-5 second window usually settles the noise enough to decide.
A common pattern is benchmarking the same function across different input sizes. b.Run creates sub-benchmarks with their own names, similar to how t.Run creates subtests:
The output shows one row per sub-benchmark:
The ns/op scales linearly with the size, which confirms Total is O(n). If you saw a non-linear pattern, that would tell you the function does something more expensive than a single pass.
The full mechanics of b.Run (cleanup, parallel execution, naming rules) are the same as t.Run for subtests.
The Go compiler is aggressive about removing dead code. If a benchmark computes a value and never uses it, the compiler may notice and skip the computation entirely, making the benchmark report a few nanoseconds per op no matter how heavy the work is. The defense is to assign the result to a package-level variable so the compiler can't prove it's dead:
Assigning to sinkFloat after the loop makes the result observable outside the benchmark function, so the compiler has to actually compute it. Without this trick, an experienced reviewer would look at the report and ask "are you sure the loop ran?".
A useful sanity check: if a benchmark reports something like 0.3 ns/op or 1.0 ns/op for non-trivial work, the optimizer probably ate the body. Real work involving even a tiny array sum is at least a few nanoseconds. The fix is always the sink.
What's wrong with this benchmark?
total is declared, written to, and never read. The compiler can prove it's dead and may remove the inner loop entirely. The benchmark might report 0.3 ns/op even though the cart has five elements. Fix: assign total to a package-level sinkFloat after the inner loop, or use b.ReportMetric (not covered here) to report total as a metric.
When you change an implementation and rerun a benchmark, the raw numbers can move by a few percent just from noise. benchstat is the official tool for comparing two sets of benchmark output and telling you whether the difference is real or noise:
The output shows the percentage delta and a statistical confidence bound, so you can tell "5% faster, p=0.01" from "noise". The -count=10 flag runs each benchmark 10 times to give benchstat enough samples to work with. The tool itself lives at golang.org/x/perf/cmd/benchstat; this chapter just flags that it exists. The benchstat docs cover the math.
Here's a complete benchmark file for the cart total and receipt builder, written the way you'd commit it:
Running with -bench=. gives a clean comparison across cart sizes and string-building strategies:
The cart total scales linearly with size (10x size, 10x time), as expected for an O(n) sum. Both cart benchmarks show 0 allocations because cartTotal only reads from an existing slice. The receipt comparison favors strings.Builder: half the time, less than half the bytes, and fewer than half the allocations.
10 quizzes