Go Benchmarking: Measuring Performance with testing.B
Reviewer’s note: This tutorial shows you how to write Go benchmarks that report numbers you can actually trust. You will read
ns/op,B/opandallocs/op, dodge the trap where the compiler deletes the code you meant to measure, compare two implementations frankly, and optimize a real function with before and after numbers. Every benchmark here was run on Go 1.24.7.
Works with Go 1.24+ for the b.Loop form. The classic b.N loop works back to Go 1.7. If you are new to Go, read the complete Go tutorial first, and see the Go testing guide for how tests and benchmarks share the same tooling.
A note on the numbers before you trust any of them: everything below ran on a shared 2-vCPU cloud VM (Intel Xeon at 2.10 GHz, GOMAXPROCS=2). Your absolute numbers will differ. What holds across machines is the relative result: which version allocates less, which is faster, by roughly what factor. Treat every nanosecond figure here as “on this machine, this run,” never as a spec.
A benchmark is a function named BenchmarkXxx that runs the work b.N times
A Go benchmark lives in a _test.go file, takes *testing.B, and runs the code under test in a loop that executes b.N times. You never set b.N yourself. The testing framework starts small, times the loop, and keeps increasing b.N until the run lasts long enough (one second by default) to produce a stable per-operation number. That is the whole model: run the operation many times, divide total time by the count.
Here is a benchmark for a string-reverse function.
package intro
// reverse returns s reversed, decoding runes so multi-byte characters survive.
func reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
package intro
import "testing"
var sinkStr string
func BenchmarkReverse(b *testing.B) {
for i := 0; i < b.N; i++ {
sinkStr = reverse("the quick brown fox")
}
}
Run it with go test -bench=. -benchmem. The -bench flag takes a regular expression matching benchmark names (. means all); -benchmem adds the memory columns.
$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: intro
cpu: Intel(R) Xeon(R) Processor @ 2.10GHz
BenchmarkReverse-2 6897426 171.7 ns/op 24 B/op 1 allocs/op
PASS
ok intro 3.734s
The -2 suffix on the name is GOMAXPROCS, the number of CPUs the run used. 6897426 is the final b.N: the framework decided it needed almost 7 million iterations to fill a second. The columns that matter come next.
The framework picks b.N this way because a single call to a nanosecond-scale function is unmeasurable; clock resolution and one-off costs would swamp it. Running it millions of times and dividing amortizes that noise away. The testing package docs describe the ramp-up in detail.
Reading ns/op, B/op and allocs/op
Four numbers describe each benchmark. Read them left to right:
6897426: iterations, the finalb.N. Higher usually means a faster operation. On its own it means little.171.7 ns/op: nanoseconds per operation, total time divided by iterations. This is your speed number.24 B/op: bytes allocated on the heap per operation. Here, the returned string.1 allocs/op: number of distinct heap allocations per operation.
The last two only appear when you pass -benchmem (or call b.ReportAllocs() inside the benchmark). They are often more useful than ns/op, because allocations are the thing you can most reliably cut, and each one feeds the garbage collector. A function that drops from 5 allocs/op to 0 will usually get faster and reduce GC pressure across the whole program, which a raw timing on one machine will not show you.
Notice reverse reports only 1 alloc even though it builds a []rune and a string. Escape analysis kept the rune slice on the stack; only the returned string escapes to sinkStr. That is the kind of detail -benchmem surfaces and a stopwatch never would.
The trap: the compiler deletes work you do not use
This is the single most common way Go benchmarks lie, and most tutorials never mention it. If your benchmark computes a value and throws it away, the compiler is free to notice the result is unused, prove the call has no side effects, and delete it. You then measure an empty loop.
Here is a small, inlinable hash-mixing function, benchmarked the naive way.
package benchdemo
// mix is one round of a bit-mixing hash. Small and side-effect-free,
// so it is a prime target for the compiler's dead-code elimination.
func mix(x uint64) uint64 {
x ^= x >> 33
x *= 0xff51afd7ed558ccd
x ^= x >> 33
x *= 0xc4ceb9fe1a85ec53
x ^= x >> 33
return x
}
package benchdemo
import "testing"
var input uint64 = 8675309
// BAD: result discarded. The whole call can be eliminated.
func BenchmarkMixNaive(b *testing.B) {
for i := 0; i < b.N; i++ {
mix(input)
}
}
var sinkU uint64
// GOOD: store the result in a package-level sink the compiler must keep.
func BenchmarkMixSink(b *testing.B) {
var r uint64
for i := 0; i < b.N; i++ {
r = mix(input)
}
sinkU = r
}
$ go test -bench=Mix -benchmem
BenchmarkMixNaive-2 1000000000 0.2024 ns/op 0 B/op 0 allocs/op
BenchmarkMixSink-2 1000000000 0.8298 ns/op 0 B/op 0 allocs/op
Look at BenchmarkMixNaive: 0.2024 ns/op. At 2.1 GHz that is well under one clock cycle. No real function that does five multiplies and shifts runs in a fraction of a cycle. The compiler deleted the call, and you are timing i++. That number is not slightly wrong, it is meaningless.
The fix is a sink: assign the result to a package-level variable the compiler cannot prove is dead. BenchmarkMixSink reports 0.8298 ns/op, four times higher and actually the cost of the work. The sink must be package-level (or otherwise observable). A local variable you never read gets eliminated too.
The tell is a number that is too good to be true. When you see sub-nanosecond timings or a suspiciously round 0, assume dead-code elimination until you have proven otherwise.
b.Loop (Go 1.24) removes the trap for you
Go 1.24 added b.Loop(), and it is now the recommended way to write the loop. Instead of for i := 0; i < b.N; i++, you write for b.Loop(). The framework guarantees the loop body is not optimized away: arguments stay alive and results are kept, so you no longer need a sink for the common case.
// Go 1.24: no sink needed; b.Loop keeps the call alive.
func BenchmarkMixLoop(b *testing.B) {
for b.Loop() {
mix(input)
}
}
BenchmarkMixLoop-2 1000000000 1.168 ns/op 0 B/op 0 allocs/op
b.Loop reports 1.168 ns/op here, in the same range as the hand-written sink and safe from elimination. It has a second benefit: any setup you write before the loop runs exactly once, even when the framework re-runs the loop internally. The Go 1.24 release notes cover it, and the testing.B.Loop docs are the reference. Use for b.Loop() in new code. You still need to recognize the b.N form, because most existing benchmarks use it and it is not going away.
Keep setup out of the timed section
If your benchmark does expensive setup, the clock must not include it. There are two cases.
One-time setup goes before the loop, followed by b.ResetTimer() to discard the setup time. With b.Loop, the pre-loop code is already excluded, but ResetTimer is still the explicit, version-agnostic way to say “start timing here.”
Per-iteration setup is the harder case: you need a fresh input each iteration but must not time building it. Wrap it in b.StopTimer() and b.StartTimer().
package intro
import (
"sort"
"testing"
)
// Per-iteration setup: sort.Ints mutates its input, so each iteration needs a
// fresh unsorted slice. Pause the clock while building it.
func BenchmarkSortWithSetup(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := make([]int, 1000)
for j := range data {
data[j] = (j * 2654435761) % 1000 // deterministic pseudo-shuffle
}
b.StartTimer()
sort.Ints(data)
}
}
BenchmarkSortWithSetup-2 104540 11628 ns/op 0 B/op 0 allocs/op
The 11628 ns/op is the sort alone, not the slice construction. Forget the StopTimer/StartTimer pair and you would fold the make and the fill loop into every measurement, inflating the number and hiding what you actually wanted to know. (sort runs in place here, so it reports 0 allocs; the allocation happened in the paused setup.) One caution: StopTimer/StartTimer has overhead of its own, so if the per-iteration setup is tiny, restructure to avoid pausing on every single iteration.
b.ReportAllocs() is the other setup helper worth knowing. Call it inside a benchmark to force the allocation columns for that benchmark even when you forget -benchmem on the command line. It is a good habit for any benchmark where allocations are the point.
Compare input sizes with sub-benchmarks and b.Run
A single input size tells you one point on a curve. b.Run creates named sub-benchmarks, so you can sweep sizes in one function and see how cost scales. This is where benchmarking earns its keep: comparing + string concatenation against strings.Builder.
package strbench
import "strings"
// joinWithPlus builds a string with repeated +. Each += allocates a new
// backing array and copies everything so far: O(n^2) total.
func joinWithPlus(parts []string) string {
out := ""
for _, p := range parts {
out += p
}
return out
}
// joinWithBuilder grows one buffer.
func joinWithBuilder(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}
package strbench
import (
"fmt"
"testing"
)
func makeParts(n int) []string {
parts := make([]string, n)
for i := range parts {
parts[i] = "segment"
}
return parts
}
func BenchmarkJoinSizes(b *testing.B) {
for _, size := range []int{10, 100, 1000} {
parts := makeParts(size)
b.Run(fmt.Sprintf("plus-%d", size), func(b *testing.B) {
for b.Loop() {
joinWithPlus(parts)
}
})
b.Run(fmt.Sprintf("builder-%d", size), func(b *testing.B) {
for b.Loop() {
joinWithBuilder(parts)
}
})
}
}
$ go test -bench=JoinSizes -benchmem
BenchmarkJoinSizes/plus-10-2 1467643 791.9 ns/op 440 B/op 9 allocs/op
BenchmarkJoinSizes/builder-10-2 3480961 334.5 ns/op 248 B/op 5 allocs/op
BenchmarkJoinSizes/plus-100-2 51819 23395 ns/op 37000 B/op 99 allocs/op
BenchmarkJoinSizes/builder-100-2 877701 1385 ns/op 1912 B/op 8 allocs/op
BenchmarkJoinSizes/plus-1000-2 906 1774683 ns/op 3717256 B/op 999 allocs/op
BenchmarkJoinSizes/builder-1000-2 86044 25454 ns/op 34296 B/op 15 allocs/op
Read down the allocs/op column and the two approaches diverge. + allocates once per element (99, then 999), because each concatenation builds a whole new string. strings.Builder stays nearly flat (8, then 15). At 1000 parts, + costs 1,774,683 ns/op and 3.7 MB per call; the builder costs 25,454 ns and 34 KB. That is roughly 70 times faster and 100 times less memory, and the gap widens with size because + is quadratic. The numbers make the case that a code review comment (“use strings.Builder”) only asserts. If you are fuzzy on why each + reallocates, the slices and maps guide explains the backing-array growth underneath.
Why single runs lie, and what benchstat does about it
Run the same benchmark twice and you will not get the same number. During development I measured the word-count function in the next section at 57,637 ns/op on one run and 95,043 ns/op on another, on the same machine with the same code. A shared VM has noisy neighbors, CPU frequency scaling, and background work, and all of it lands in your timings. Comparing one run of version A against one run of version B is how you “prove” an improvement that is really just noise.
The fix is to run each benchmark several times with -count and compare distributions, not single values:
$ go test -bench=Count -benchmem -count=6 > new.txt
Then feed the output to benchstat, the standard tool from golang.org/x/perf/cmd/benchstat. It reads the repeated samples, reports the median with a variation percentage, and computes whether the difference between two sets of runs is statistically significant (a p-value) rather than noise. You install it with go install golang.org/x/perf/cmd/benchstat@latest and typically run benchstat old.txt new.txt to compare a baseline against a change. (It is a golang.org/x tool outside the standard library, so it is not covered by the tested output in this article; the workflow is what matters.) The rule to internalize: never trust a single benchmark run, and never report an optimization without repeated measurements behind it.
Layer 3: optimizing a real function, measured before and after
Here is the full loop as you would run it in a real codebase: write it clearly, measure, improve, measure again. The function counts words in a string.
Version one is the obvious one. Split into a slice, take the length.
package words
import "strings"
func countWordsFields(text string) int {
return len(strings.Fields(text))
}
Before benchmarking two implementations, write a test that proves they agree. A faster function that returns the wrong answer is not an optimization. (See unit testing in Go for the mechanics.)
Benchmark version one on a 500-paragraph input, with b.ReportAllocs() and b.ResetTimer() after the setup:
BenchmarkCountFields-2 3998 56000 ns/op 73728 B/op 1 allocs/op
strings.Fields allocates a []string we use only to count. That is the 73728 B/op. A profile would point straight at that allocation, so the first instinct is to remove it by scanning runes and counting word starts without building a slice:
import "unicode"
func countWordsScan(text string) int {
count, inWord := 0, false
for _, r := range text {
if unicode.IsSpace(r) {
inWord = false
} else if !inWord {
inWord = true
count++
}
}
return count
}
It allocates zero. But when I measured it, it ran at roughly 65,000 to 74,000 ns/op, not faster than the original, sometimes slower. Removing the allocation did not help, because ranging over a string decodes UTF-8 rune by rune and unicode.IsSpace does a full Unicode table lookup per character. This is the whole reason you measure: the intuitive optimization was a wash. If I had shipped it on faith I would have added complexity for nothing.
The profile-guided version drops rune decoding and uses a cheap ASCII space test over raw bytes:
func countWordsBytes(text string) int {
count, inWord := 0, false
for i := 0; i < len(text); i++ {
c := text[i]
isSpace := c == ' ' || c == '\t' || c == '\n' ||
c == '\r' || c == '\v' || c == '\f'
if isSpace {
inWord = false
} else if !inWord {
inWord = true
count++
}
}
return count
}
Measured over six runs each:
BenchmarkCountFields-2 3998 56000 ns/op 73728 B/op 1 allocs/op
BenchmarkCountBytes-2 8484 28325 ns/op 0 B/op 0 allocs/op
Roughly 2x faster and zero allocations. That is a real improvement, and it is real precisely because a measurement rejected the middle attempt. The byte scan changes behavior for non-ASCII whitespace, which is exactly why the agreement test comes first and why you would document the tradeoff. Speed you cannot verify is not speed you can ship.
Common benchmarking mistakes
Timing your setup. Building test data inside the timed loop measures the setup, not the work. Use b.ResetTimer() after one-time setup and b.StopTimer()/b.StartTimer() around per-iteration setup.
Letting the compiler eliminate the work. A discarded result can be deleted, giving fake sub-nanosecond numbers like the 0.2024 ns/op above. Assign to a package-level sink, or use for b.Loop() on Go 1.24+.
Trusting one run. A single number includes machine noise. Use -count and compare with benchstat before claiming anything got faster.
Optimizing before measuring. The rune-scan version felt faster and was not. Profile first, benchmark the change, keep it only if the numbers agree. Guessing wastes effort and often adds bugs.
Microbenchmarks that do not reflect real work. A function that is fast on a 20-character string may be slow on the 2 KB inputs production sends it, and a benchmark that reuses one cached input hides allocation costs that appear under real load. Benchmark realistic sizes and realistic data, and treat a microbenchmark as one signal, not proof about the whole system.
What next
You can now write benchmarks that survive scrutiny: real per-operation numbers, allocation counts, frank comparisons, and a habit of measuring before and after.
- Go testing: the complete guide is the pillar for this topic, covering how benchmarks sit alongside unit and table-driven tests in the same tooling.
- Go slices and maps explains the backing-array growth that made
+concatenation quadratic in the comparison above. - The Go garbage collector shows why the
allocs/opnumber you now measure matters beyond a single function. - Come back to the main Go tutorial to see where performance work fits in the larger picture.
Reference docs worth bookmarking: the testing package and testing.B.Loop.
