Introduction
Optimization without measurement is guesswork. Go ships a first-class benchmarking framework in the testing package that lets you measure execution time and memory allocations with a single command. This lesson covers how to write benchmarks, interpret results, and avoid the measurement traps that produce misleading numbers.
Key Concepts
- testing.B: The type passed to benchmark functions. It controls the iteration count (
b.N), timer, and allocation tracking. - b.N: An integer that the test runner adjusts automatically. Your benchmark loop runs
b.Ntimes, and the framework increasesb.Nuntil the total execution time is statistically stable. - b.ResetTimer(): Zeroes the benchmark clock. Call this after expensive setup so that setup time is excluded from the measurement.
- b.ReportAllocs(): Enables per-operation memory allocation reporting in the benchmark output.
Real World Context
You are deciding between strings.Builder and fmt.Sprintf for generating log messages in a hot path. Rather than guessing, you write two benchmarks and run go test -bench=. -benchmem. The output shows that strings.Builder is 3x faster and allocates half the memory per operation. You commit the benchmark alongside your code so that future refactors can be validated against the same baseline.
Deep Dive
Benchmark functions live in _test.go files and start with Benchmark followed by a capitalized name. They accept a single *testing.B parameter.
Here is the simplest possible benchmark.
gofunc BenchmarkLogic(b *testing.B) { for i := 0; i < b.N; i++ { Logic() } }
The framework calls this function multiple times, increasing b.N each round until the total execution time is long enough (at least 1 second by default) to produce reliable statistics. You never set b.N yourself.
Benchmarks do not run by default. You must pass the -bench flag to go test.
bashgo test -bench=. # All benchmarks go test -bench=BenchmarkLogic # Specific benchmark go test -bench=. -benchmem # Include memory stats go test -bench=. -count=5 # Multiple runs for stability
The -benchmem flag adds two columns to the output: bytes allocated per operation and allocations per operation. The -count flag runs the benchmark multiple times so you can spot variance.
Excluding Setup from Measurement
If your benchmark requires expensive setup (loading data, building structures), perform it before the loop and call b.ResetTimer() to exclude that time.
gofunc BenchmarkSort(b *testing.B) { data := generateData(10000) // Setup b.ResetTimer() // Don't count setup time for i := 0; i < b.N; i++ { sort.Ints(data) } }
Without b.ResetTimer(), the setup time is included in the measurement, inflating the reported ns/op and making your benchmark useless for comparison.
Tracking Memory Allocations
Call b.ReportAllocs() inside the benchmark to include allocation statistics even without the -benchmem flag.
gofunc BenchmarkConcat(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { _ = fmt.Sprintf("hello %s", "world") } }
The output includes B/op (bytes per operation) and allocs/op (heap allocations per operation). Reducing allocations often has a bigger impact on performance than reducing CPU time because it reduces garbage collector pressure.
Sub-benchmarks
Just like tests, benchmarks support b.Run for parameterized comparisons.
gofunc BenchmarkHash(b *testing.B) { sizes := []int{64, 256, 1024, 4096} for _, size := range sizes { b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { data := make([]byte, size) b.ResetTimer() for i := 0; i < b.N; i++ { sha256.Sum256(data) } }) } }
Sub-benchmarks appear in the output with hierarchical names like BenchmarkHash/size=64, making it easy to compare performance across input sizes.
As of Go 1.26, benchmark functions also have access to b.ArtifactDir(), which returns a unique per-benchmark directory for writing profile data or output files. This pairs well with -cpuprofile and -memprofile flags for deep performance analysis.
Common Pitfalls
- Doing setup inside the benchmark loop -- Any work inside the
for i := 0; i < b.Nloop is measured. If you allocate test data inside the loop, you are benchmarking allocation plus your logic. - Letting the compiler optimize away the result -- If the result of the benchmarked function is never used, the compiler may eliminate the call entirely. Assign the result to a package-level variable:
var result intandresult = Logic()inside the loop. - Running benchmarks on a loaded machine -- Background processes, thermal throttling, and power management cause variance. Use
-count=5or more and look for consistency across runs.
Best Practices
- Always use -benchmem -- Memory allocations are often the real bottleneck. Measuring only CPU time misses half the picture.
- Use benchstat for comparisons -- The
golang.org/x/perf/cmd/benchstattool compares benchmark results across runs and reports whether differences are statistically significant. - Commit benchmarks alongside code -- Benchmarks serve as performance regression tests. When a refactor slows things down, the benchmark catches it before it ships.
Summary
- Benchmark functions start with
Benchmark, accept*testing.B, and loopb.Ntimes. - The framework automatically adjusts
b.Nfor statistically stable results. - Use
b.ResetTimer()to exclude setup andb.ReportAllocs()to track memory. - Sub-benchmarks with
b.Runenable parameterized performance comparisons. - Go 1.26 adds
b.ArtifactDir()for per-benchmark output files alongside CPU and memory profiles.
Code Examples
go test -bench . -benchmem -cpuprofile=cpu.out