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
Benchmarkfollowed by a capital letter (or digit/underscore);go testdiscovers it automatically, no registration needed. - b *testing.B — the benchmark handle. Besides
b.N, you’ll commonly reach forb.ResetTimer(),b.StopTimer()/b.StartTimer(),b.ReportAllocs(), andb.Run()for sub-benchmarks. - b.N — the iteration count chosen by the test runner during calibration; your loop must run its body exactly
b.Ntimes. - location — benchmarks live in
_test.gofiles, in the same package as (or an external_testpackage 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 testcompiles a test binary containing your package plus all_test.gofiles, exactly as it does for ordinary tests. - 2. The test binary starts, runs any matching
TestXxxfunctions 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 smallb.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
-benchtimespecifies), it computes a largerb.Ndesigned to hit that target and calls the function again. - 5. This repeats, with
b.Ngrowing 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.Nto get nanoseconds per operation, and (if allocation reporting is enabled) divides total bytes allocated and total allocation count byb.Ntoo. - 7. The result line for each benchmark (or each
b.Runsub-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 useb.StopTimer()/b.StartTimer()around it) to exclude one-time setup cost from the measured time. - Enable
b.ReportAllocs()or pass-benchmemso you seeB/opandallocs/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.Runwith a table of named sub-benchmarks to compare implementations, or the same implementation across input sizes, in a singlego testinvocation. - Run benchmarks more than once with
-count=5or higher and compare results with a statistical tool likebenchstatinstead 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
-cpuprofileor-memprofilewithgo test -benchwhen 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
BenchmarkReversefor it. Rungo test -bench=. -benchmemand note the reportedallocs/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]booland then looks up the key. Write table-driven sub-benchmarks withb.Runthat 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
BenchmarkBuildSliceexample from this lesson, remove theb.ResetTimer()call, run it, then add the call back and run it again. Compare the twons/opresults and explain the difference in your own words.
Summary
- A benchmark is a
func BenchmarkXxx(b *testing.B)function in a_test.gofile, discovered automatically and run withgo test -bench=<pattern>. - The loop body must use
for i := 0; i < b.N; i++— the runner controlsb.Nthrough a calibration process that grows it until timing stabilizes. - Use
b.ReportAllocs()or-benchmemto 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.Runfor table-driven sub-benchmarks that compare alternative implementations or input sizes in one command. - Trust averages, not single runs: use
-countplus a comparison tool likebenchstatfor real confidence that a change made things faster or slower.
