Benchmarks
A Benchmark function runs your code many times and reports ns/op, B/op and allocs/op so a change can be measured.
func BenchmarkXxx(b *testing.B) { for b.Loop() { ... } } — run with: go test -bench=. -benchmemA benchmark measures how long a piece of code takes and how much memory it uses. It exists so that "this is faster" becomes a number instead of an opinion. The same toolchain that runs tests runs benchmarks, from the same _test.go files.
Writing one
A benchmark is a function whose name begins with Benchmark and that takes b *testing.B. Inside, you run the code under test repeatedly in a loop. Repetition is what makes the measurement usable: one call to a fast function is lost in timing noise, so the toolchain runs it until the total is long enough to mean something.
There are two loop forms. for b.Loop() { ... } was added in Go 1.24 and is the one to write now. for i := 0; i < b.N; i++ { ... } is the older longhand and still works; b.N is the number of repetitions the toolchain chose. b.Loop has two advantages: setup written before the loop is excluded from the timing automatically, and it stops the compiler from deleting work whose result nobody uses. With the b.N form you need b.ResetTimer() after the setup, and you must store the result somewhere, usually a package-level variable, or the compiler may optimise the call away and report an impossibly fast time.
Reading the output
Run go test -bench=. -benchmem. A result line reads BenchmarkJoin-8 12076358 98.2 ns/op 56 B/op 3 allocs/op: the -8 is the number of processors available, then the repetition count, the nanoseconds per call, the bytes allocated per call and the number of allocations per call. Allocation counts are far more stable between machines than times, so they are the better thing to compare.
Example
// File: join_test.go — run it with: go test -bench=BenchmarkJoin -benchmem
package join
import (
"strings"
"testing"
)
// Join glues the parts together with commas using a Builder.
func Join(parts []string) string {
var b strings.Builder
for i, p := range parts {
if i > 0 {
b.WriteString(",")
}
b.WriteString(p)
}
return b.String()
}
func BenchmarkJoin(b *testing.B) {
// Work before the loop is not included in the reported time.
parts := []string{"alpha", "beta", "gamma", "delta"}
// b.Loop needs Go 1.24 or later.
for b.Loop() {
Join(parts)
}
}
// Output (the figures depend on the machine; -8 is the processor count):
// goos: linux
// goarch: amd64
// pkg: example.com/app/join
// BenchmarkJoin-8 12076358 98.2 ns/op 56 B/op 3 allocs/op
// PASS
When to use it
- Prove that a change to a hot function made it faster, with numbers you can paste into the pull request.
- Find out how many allocations a request path performs before trying to reduce them.
- Compare two libraries or two data structures under identical conditions as sub-benchmarks.
More examples
Comparing two implementations side by side
b.Run creates a named sub-benchmark exactly as t.Run creates a subtest. Two sub-benchmarks in one function run under identical conditions, which is the only fair way to compare approaches. The figures shown are illustrative: what matters is the ratio between the two lines on your own machine.
func BenchmarkConcat(b *testing.B) {
parts := []string{"alpha", "beta", "gamma", "delta"}
b.Run("plus", func(b *testing.B) {
for b.Loop() {
s := ""
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for b.Loop() {
Join(parts)
}
})
}
// BenchmarkConcat/plus-8 9142053 130.0 ns/op 112 B/op 3 allocs/op
// BenchmarkConcat/builder-8 12076358 98.2 ns/op 56 B/op 3 allocs/opMeasuring the cost of not preallocating
This is the benchmark worth running before any clever optimisation. Growing a slice reallocates and copies repeatedly; giving make the capacity you already know collapses that to one allocation. Run it with -benchmem and compare allocs/op, which will differ by roughly ten to one.
func BenchmarkCollect(b *testing.B) {
b.Run("no capacity", func(b *testing.B) {
for b.Loop() {
var out []int
for i := 0; i < 1000; i++ {
out = append(out, i)
}
_ = out
}
})
b.Run("preallocated", func(b *testing.B) {
for b.Loop() {
out := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
out = append(out, i)
}
_ = out
}
})
}The older b.N loop, written correctly
You will meet this form in any codebase older than Go 1.24, so you need to read it. b.N is the repetition count chosen by the toolchain, b.ResetTimer drops everything timed so far, and b.ReportAllocs turns on the B/op and allocs/op columns for this benchmark without the -benchmem flag. for b.Loop() handles the first two concerns on its own — it resets the timer the first time it is called, and it keeps call arguments and results alive so the loop body is not optimised away — but it does not switch on allocation reporting, so B/op and allocs/op still need b.ReportAllocs or the -benchmem flag.
// A package-level variable the compiler cannot prove is unused.
var sink string
func BenchmarkJoinClassic(b *testing.B) {
parts := []string{"alpha", "beta", "gamma", "delta"}
// Discard the time spent on setup above.
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Storing the result keeps the call from being optimised away.
sink = Join(parts)
}
b.ReportAllocs()
}
Discussion