Benchmarks

A benchmark in Go is a small, repeatable performance test written with the standard testing package that measures how fast a piece of code runs and how much memory it allocates while running. Instead of guessing whether one implementation is faster than another, or whether a recent change made something slower, Go lets you write a BenchmarkXxx function, run it with go test -bench, and get hard numbers: nanoseconds per operation, bytes allocated per operation, and allocations per operation. Because benchmarks live next to your tests and use the same go test tooling, measuring performance becomes a routine part of writing Go rather than a separate, ad-hoc exercise.

Overview: How Benchmarks Work

A benchmark is just a function with the signature func BenchmarkXxx(b *testing.B), placed in a file ending in _test.go, next to the code it measures. The Xxx must start with an uppercase letter (or a non-letter), exactly like the naming rule for TestXxx functions. The *testing.B parameter is similar to *testing.T — it embeds the same testing.common type, so it has Log, Error, Fatal, and friends — but it adds benchmark-specific fields and methods, the most important of which is b.N.

You never choose b.N yourself. The benchmark runner calibrates it for you: it calls your function once with a small b.N, measures how long the loop took, and if that was too short to trust (by default, benchmarks run for at least one second of wall-clock time), it multiplies b.N and tries again — 1, then roughly 2, 5, 10, 20, 50, 100, and so on — until the loop body runs long enough to produce a stable per-operation timing. This is why every benchmark body must contain a loop of the exact shape for i := 0; i < b.N; i++ { ... }: the runner is repeatedly re-running that loop with different iteration counts and dividing the total elapsed time by b.N to compute nanoseconds per operation. If you hardcode the loop bound instead of using b.N, the calibration is defeated and the reported numbers become meaningless.

Under the hood, go test -bench compiles a special test binary (the same one used for go test) and, for each matching benchmark, starts a wall-clock timer immediately before entering your loop and stops it immediately after. Between calibration runs, Go’s runtime also tracks allocation counters so that, when you opt in with b.ReportAllocs() or the -benchmem flag, it can report B/op (bytes allocated per operation) and allocs/op (number of heap allocations per operation) alongside the timing. These allocation numbers are frequently more useful than raw speed, because a function that allocates less will usually put less pressure on the garbage collector, which pays off in ways a single benchmark’s timing might not fully reveal.

Benchmarks are opt-in: a plain go test run does not execute them, because running to a full second (or longer) per benchmark would make routine test runs unbearably slow. You must explicitly ask for benchmarks with the -bench flag, which takes a regular expression matched against benchmark names.

Syntax

func BenchmarkXxx(b *testing.B) {
	// optional one-time setup
	for i := 0; i < b.N; i++ {
		// code being measured
	}
}
  • BenchmarkXxx — the function name must start with Benchmark followed by a capital letter (or digit/underscore); go test discovers it automatically, no registration needed.
  • b *testing.B — the benchmark handle. Besides b.N, you’ll commonly reach for b.ResetTimer(), b.StopTimer()/b.StartTimer(), b.ReportAllocs(), and b.Run() for sub-benchmarks.
  • b.N — the iteration count chosen by the test runner during calibration; your loop must run its body exactly b.N times.
  • location — benchmarks live in _test.go files, in the same package as (or an external _test package for) the code they measure.

Key testing.B members

Member Purpose
b.N Number of iterations to run; set by the calibration loop.
b.ResetTimer() Zeroes the elapsed time and allocation counters, discarding any setup cost measured so far.
b.StopTimer() / b.StartTimer() Pause and resume timing around code inside the loop that shouldn’t be measured.
b.ReportAllocs() Includes B/op and allocs/op in the report (same effect as the -benchmem flag).
b.Run(name, func(b *testing.B)) Runs a named sub-benchmark, useful for table-driven comparisons.

Useful go test flags

Flag Effect
-bench=<regexp> Runs benchmarks whose name matches the pattern; -bench=. runs all of them.
-benchmem Adds memory allocation statistics to the output.
-benchtime=3s or -benchtime=100x Runs each benchmark for a given duration, or for an exact iteration count.
-count=5 Repeats each benchmark multiple times, useful for statistical comparison with a tool like benchstat.
-run=^$ Skips all regular tests, so only benchmarks execute.

Examples

Example 1: Benchmarking a simple function

Here is an ordinary function, fibonacci, computed iteratively:

package main

import "fmt"

func fibonacci(n int) int {
	if n < 2 {
		return n
	}
	a, b := 0, 1
	for i := 2; i <= n; i++ {
		a, b = b, a+b
	}
	return b
}

func main() {
	fmt.Println(fibonacci(10))
}

Output:

55

If this lived in fib.go, the accompanying benchmark would live in fib_test.go, in the same package, like this:

package main

import "testing"

func BenchmarkFibonacci(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fibonacci(10)
	}
}

Running go test -bench=. -benchmem produces output like this:

$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: example.com/fibdemo
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkFibonacci-8   	42184476	        28.15 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.com/fibdemo	1.782s

Read the result line left to right: BenchmarkFibonacci-8 is the benchmark name plus the number of logical CPUs used (GOMAXPROCS, here 8); 42184476 is the final b.N the runner settled on; 28.15 ns/op is the average time per call to fibonacci(10); and 0 B/op / 0 allocs/op show that this particular function performs no heap allocations at all, since it only uses local int variables.

Example 2: Comparing two implementations with sub-benchmarks

Benchmarks become much more useful when you compare alternatives side by side. Here are two ways to concatenate strings:

package main

import (
	"fmt"
	"strings"
)

func concatPlus(words []string) string {
	result := ""
	for _, w := range words {
		result += w
	}
	return result
}

func concatBuilder(words []string) string {
	var b strings.Builder
	for _, w := range words {
		b.WriteString(w)
	}
	return b.String()
}

func main() {
	words := []string{"go", "is", "fast"}
	fmt.Println(concatPlus(words))
	fmt.Println(concatBuilder(words))
}

Output:

goisfast
goisfast

Both functions produce the same string, so a correctness test alone can’t tell you which one to prefer. A table-driven benchmark using b.Run can:

package main

import "testing"

var wordsBench = []string{"go", "is", "fast", "and", "fun", "to", "learn"}

func BenchmarkConcat(b *testing.B) {
	b.Run("Plus", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			concatPlus(wordsBench)
		}
	})
	b.Run("Builder", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			concatBuilder(wordsBench)
		}
	})
}
$ go test -bench=. -benchmem
BenchmarkConcat/Plus-8       	 3211584	       372.4 ns/op	      48 B/op	       3 allocs/op
BenchmarkConcat/Builder-8    	10456230	       114.2 ns/op	      32 B/op	       1 allocs/op
PASS
ok  	example.com/concatdemo	2.531s

Each b.Run call creates an independently calibrated sub-benchmark, and the results show why strings.Builder is the idiomatic choice for building strings in a loop: it is roughly three times faster here and makes a single allocation instead of three, because it grows one internal buffer instead of creating a brand-new string on every +=.

Example 3: Excluding setup cost with ResetTimer

Sometimes a benchmark needs expensive setup data that itself shouldn’t count toward the timing. Consider a function that builds a slice of squares:

package main

import "fmt"

func buildSlice(n int) []int {
	s := make([]int, 0, n)
	for i := 0; i < n; i++ {
		s = append(s, i*i)
	}
	return s
}

func main() {
	s := buildSlice(5)
	fmt.Println(s)
}

Output:

[0 1 4 9 16]

A benchmark that first builds a one-million-element input slice, then measures how fast buildSlice runs against it, should not let the input construction itself pollute the measured time:

package main

import "testing"

var sink []int

func BenchmarkBuildSlice(b *testing.B) {
	hugeInput := make([]int, 1_000_000)
	for i := range hugeInput {
		hugeInput[i] = i
	}

	b.ResetTimer()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		sink = buildSlice(len(hugeInput))
	}
}

The call to b.ResetTimer() discards the time and allocations spent building hugeInput, so the reported numbers reflect only the loop that follows. Assigning to the package-level sink variable, instead of discarding the return value, is a second important detail explained in Common Mistakes below.

How It Works Step by Step

When you run go test -bench=. -benchmem, here is the sequence that actually happens:

  • 1. go test compiles a test binary containing your package plus all _test.go files, exactly as it does for ordinary tests.
  • 2. The test binary starts, runs any matching TestXxx functions first (unless you pass -run=^$ to skip them), then moves on to benchmarks matching -bench.
  • 3. For each matching BenchmarkXxx, the runner calls it once with a small b.N (often 1) as a trial run.
  • 4. It measures the elapsed wall-clock time of that trial. If the total time is well under the target run length (one second by default, or whatever -benchtime specifies), it computes a larger b.N designed to hit that target and calls the function again.
  • 5. This repeats, with b.N growing each round, until a run takes long enough to trust, or until -benchtime‘s explicit iteration count is reached.
  • 6. The runner divides the final run’s elapsed time by its b.N to get nanoseconds per operation, and (if allocation reporting is enabled) divides total bytes allocated and total allocation count by b.N too.
  • 7. The result line for each benchmark (or each b.Run sub-benchmark) is printed, and the process repeats for the next benchmark function.

Common Mistakes

Mistake 1: Hardcoding the loop count instead of using b.N

Wrong:

func BenchmarkFibonacciWrong(b *testing.B) {
	for i := 0; i < 1000; i++ {
		fibonacci(10)
	}
}

This ignores the calibration loop entirely. The benchmark always runs exactly 1000 iterations no matter how long that takes, so the runner can never expand or shrink the run to hit a stable measurement duration, and the numbers it reports (which assume b.N was respected) become unreliable or wildly inconsistent between runs.

Correct:

func BenchmarkFibonacciRight(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fibonacci(10)
	}
}

Mistake 2: Letting the compiler eliminate the “dead” work

Wrong:

func BenchmarkFibonacciDeadCode(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fibonacci(30)
	}
}

The return value of fibonacci(30) is discarded, and the function has no observable side effects. A sufficiently aggressive compiler pass is, in principle, free to notice the result is never used and skip computing it — making the benchmark measure close to nothing instead of the real cost. The fix is to store the result somewhere the compiler cannot prove is unused, typically a package-level variable:

var sink int

func BenchmarkFibonacciFixed(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = fibonacci(30)
	}
}

Mistake 3: Forgetting b.ResetTimer() after expensive setup

Wrong:

func BenchmarkBuildSliceWrong(b *testing.B) {
	hugeInput := make([]int, 1_000_000)
	for i := range hugeInput {
		hugeInput[i] = i
	}
	for i := 0; i < b.N; i++ {
		buildSlice(len(hugeInput))
	}
}

Building hugeInput happens once, but it happens before the timer would normally be reset, and on the very first calibration call (small b.N) that one-time setup cost dominates the measurement, skewing the ns/op the runner uses to decide how to scale b.N. Calling b.ResetTimer() right before the loop — as shown in Example 3 — excludes the setup and measures only what you intend to measure.

Best Practices

  • Always loop exactly as for i := 0; i < b.N; i++ — never substitute a fixed iteration count.
  • Call b.ResetTimer() (or use b.StopTimer()/b.StartTimer() around it) to exclude one-time setup cost from the measured time.
  • Enable b.ReportAllocs() or pass -benchmem so you see B/op and allocs/op, not just speed — allocation counts are often the more actionable signal.
  • Assign results that would otherwise be discarded to a package-level variable to defeat dead-code elimination.
  • Use b.Run with a table of named sub-benchmarks to compare implementations, or the same implementation across input sizes, in a single go test invocation.
  • Run benchmarks more than once with -count=5 or higher and compare results with a statistical tool like benchstat instead of trusting a single run — timing noise from the OS scheduler and CPU frequency scaling is real.
  • Keep benchmarked code free of unrelated I/O, logging, or randomness that isn’t part of what you intend to measure.
  • Pair -cpuprofile or -memprofile with go test -bench when a benchmark’s numbers are surprising and you need to see exactly where the time or memory goes.

Practice Exercises

  • Write a function that reverses a string, then write BenchmarkReverse for it. Run go test -bench=. -benchmem and note the reported allocs/op — can you rewrite the function to reduce it?
  • Write two implementations of a “does this slice of ints contain X” check: one that does a linear scan, and one that first builds a map[int]bool and then looks up the key. Write table-driven sub-benchmarks with b.Run that compare them for a slice of 10 elements and again for a slice of 10,000 elements, and see how the winner changes with input size.
  • Take the BenchmarkBuildSlice example from this lesson, remove the b.ResetTimer() call, run it, then add the call back and run it again. Compare the two ns/op results and explain the difference in your own words.

Summary

  • A benchmark is a func BenchmarkXxx(b *testing.B) function in a _test.go file, discovered automatically and run with go test -bench=<pattern>.
  • The loop body must use for i := 0; i < b.N; i++ — the runner controls b.N through a calibration process that grows it until timing stabilizes.
  • Use b.ReportAllocs() or -benchmem to see bytes and allocations per operation, not just speed.
  • Use b.ResetTimer() to exclude expensive one-time setup from the measured time, and a package-level sink variable to stop the compiler from optimizing away unused results.
  • Use b.Run for table-driven sub-benchmarks that compare alternative implementations or input sizes in one command.
  • Trust averages, not single runs: use -count plus a comparison tool like benchstat for real confidence that a change made things faster or slower.