When the numbers stop making sense
You run go test -bench=. -benchmem and the terminal prints a table. 1423 ns/op, 384 B/op, 4 allocs/op. You run it again and the numbers shift. You change one line of code and ns/op drops by half, but allocs/op triples. It looks like a hardware review spreadsheet, but you need to know which column actually tells you if your program will survive production traffic.
Go benchmarks report three metrics. ns/op measures wall-clock time per operation. B/op measures heap bytes allocated per operation. allocs/op measures how many times the allocator carved out a new chunk of memory. Lower is better across the board, but they track different kinds of cost. Time tells you about CPU work. Bytes and allocations tell you about memory pressure and garbage collector load.
Think of it like timing a kitchen line. ns/op is how long each plate takes to leave the pass. B/op is how much raw ingredient you waste per plate. allocs/op is how many times the chef has to walk to the pantry to grab a new container. A fast chef who constantly trips to the pantry will eventually slow down the whole kitchen. A slower chef who preps everything in one station will keep the line moving.
How the benchmark runner actually works
Go does not let you pick the iteration count. The benchmark runner controls b.N. It starts with a small number, runs your loop, measures the elapsed time, and scales b.N up until the test takes roughly one second. It discards the first run to let the CPU warm up and the allocator settle. It averages the remaining runs and divides by b.N to give you per-operation metrics.
That is why b.N changes every time you hit enter. The runner is hunting for a stable sample size. Your machine load, background processes, and thermal throttling all shift the target. The runner compensates by adjusting the loop count. You just write the loop and trust the runner to find the right sample.
Here is the skeleton of every Go benchmark. The runner controls the loop count, not you.
package main
import "testing"
// BenchmarkAdd measures the cost of adding two integers.
func BenchmarkAdd(b *testing.B) {
// b.N is set by the runner. It scales until the test takes ~1s.
// Never hardcode the loop limit. The runner handles iteration math.
for i := 0; i < b.N; i++ {
// The operation you want to measure goes here.
// Assign to _ to prevent the compiler from optimizing it away.
_ = 1 + 2
}
}
If you name the function BenchmarkX but give it the wrong signature, the compiler rejects it with BenchmarkX must be a function with signature func(b *testing.B). The naming convention is strict. Every benchmark function must start with Benchmark and accept exactly one *testing.B parameter. The runner discovers functions by scanning for that prefix.
Benchmarks measure steady-state loops. They do not measure real-world latency under load.
What allocations actually cost
Heap allocations trigger the garbage collector. The Go runtime uses a concurrent, tri-color marking collector. It runs while your program executes, but it still pauses the world briefly to mark roots and sweep dead objects. Every allocation adds work to the mark phase. Every freed object adds work to the sweep phase.
B/op and allocs/op are not just academic numbers. They predict GC pause frequency. A function that allocates 128 bytes once per call will trigger far fewer GC cycles than a function that allocates 16 bytes eight times per call. The allocator has to track each chunk, update metadata, and eventually scan it. Fewer allocations mean fewer metadata updates and fewer GC cycles.
Here is a realistic comparison. Naive string concatenation versus a pre-allocated buffer.
package main
import (
"strings"
"testing"
)
// BenchmarkConcat measures naive string addition in a loop.
func BenchmarkConcat(b *testing.B) {
var s string
for i := 0; i < b.N; i++ {
// Each += creates a new string on the heap.
// The old string becomes garbage immediately.
// The allocator must track every intermediate value.
s += "hello"
}
}
// BenchmarkBuilder measures the same work using a pre-allocated buffer.
func BenchmarkBuilder(b *testing.B) {
var sb strings.Builder
for i := 0; i < b.N; i++ {
// Builder reuses its internal byte slice.
// It only grows the buffer when it runs out of space.
// Allocations happen logarithmically, not linearly.
sb.WriteString("hello")
}
}
Run both with go test -bench=. -benchmem. The concatenation benchmark will show high B/op and allocs/op. The builder benchmark will show near zero allocations after the initial buffer grows. The time difference is usually dramatic. The GC difference is even more dramatic under sustained load.
Allocation metrics predict garbage collector pressure. Optimize them before you optimize CPU cycles.
Controlling what gets measured
Real code often has setup costs. Opening a file, parsing a config, or initializing a connection pool takes time. You do not want that setup time bleeding into your per-operation average. The testing.B struct provides StopTimer(), StartTimer(), and ResetTimer() to isolate the hot path.
Here is how you measure only the critical section.
package main
import "testing"
// BenchmarkParse measures only the parsing step, not the file open.
func BenchmarkParse(b *testing.B) {
// Setup runs once outside the loop.
// The timer is paused so setup cost is excluded from ns/op.
b.StopTimer()
data := loadFixture()
b.StartTimer()
for i := 0; i < b.N; i++ {
// Reset the timer at the start of each iteration.
// This ensures only the parse call contributes to the average.
b.ResetTimer()
parseConfig(data)
}
}
The community convention is to keep setup outside the loop whenever possible. If you must reset state inside the loop, call b.ResetTimer() right before the operation you care about. The runner will still scale b.N correctly. The timer just stops counting during the parts you explicitly pause.
If you forget to call b.StartTimer() after pausing, the benchmark reports 0 ns/op and silently measures nothing. The runner does not guess your intent.
Measure only the hot path. Pause the timer for everything else.
Common pitfalls that skew results
Hardcoding the loop limit is the most frequent mistake. If you write for i := 0; i < 1000; i++, the runner averages 1000 iterations but reports the result as one operation. Your ns/op will be a thousand times larger than reality. Always use b.N as the loop bound.
Measuring I/O or network calls produces noisy data. Disk speed, network latency, and OS scheduling vary between runs. The benchmark runner cannot stabilize b.N when the underlying operation is non-deterministic. You will see wild variance in ns/op and meaningless allocation counts. Benchmark CPU and memory work. Leave I/O for integration tests.
Forgetting to reset state between iterations corrupts the average. If you append to a slice inside the loop without clearing it, the second iteration starts with leftover data. The benchmark measures growth, not steady-state performance. Reset your inputs at the top of the loop or recreate them inside the loop.
Running benchmarks on a development machine with background processes active introduces thermal throttling and CPU frequency scaling. The first run will be fast. The fifth run will be slower. Use go test -bench=. -count=5 to run multiple samples and look for consistency. Discard outliers. Average the rest.
Benchmarks lie when you feed them bad inputs. Feed them realistic data.
When to focus on which metric
Use ns/op when you are optimizing tight loops or CPU-bound algorithms. Use B/op and allocs/op when your program handles high throughput or runs for hours without restarting. Use both metrics together when you are tuning a library that other developers will embed in their own hot paths. Ignore allocation metrics when you are prototyping or measuring one-off startup costs. Use -benchtime=10s when your operation is extremely fast and you need a larger sample size for statistical stability. Use -benchmem by default so you never accidentally hide allocation regressions.
Trust the runner. Measure the hot path. Optimize allocations before you optimize cycles.