Fuzzing and benchmarks: coverage-guided inputs, b.Loop, and benchstat honesty
Native fuzzing mutates inputs coverage-guided from seeds and minimizes crashers into testdata/fuzz as permanent regressions — finding what tables cannot. Benchmarks: b.Loop (1.24) over b.N, ns/op and allocs/op via b.ReportAllocs, benchstat over -count=10 runs for honesty.
The homegrown duration parser had 31 table rows and two years of production behind it — every format anyone had ever thought of: 90s, 1.5h, 2h45m. After a public endpoint started accepting user-supplied retry intervals, someone spent ten minutes wiring a FuzzParseInterval with three seed strings and left it running over lunch. Eleven seconds in, at around 94,000 execs per second, the fuzzer printed a panic: slice bounds out of range, input ”+.” — a leading sign with no digits sent the index past the end during suffix scanning. No table row had imagined it, because every table row was written by someone who knew what a duration looks like. The minimized crasher landed in testdata/fuzz as a file, instantly a regression test that plain go test runs forever after. One crash from a public parser is a 500; the same crash from a goroutine with no recover is a process restart. The fuzzer found in seconds what two years of examples never would.
Native fuzzing: seeds, mutation, and the corpus
Since Go 1.18 fuzzing is built into go test. A fuzz target is a function FuzzXxx(f *testing.F): you register seed inputs with f.Add, then hand f.Fuzz a property that must hold for any input:
func FuzzParseInterval(f *testing.F) {
f.Add("90s")
f.Add("2h45m")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
d, err := ParseInterval(s)
if err != nil {
return // invalid input may be rejected — it must not panic
}
// round-trip property: format what we parsed, parse it again
s2 := FormatInterval(d)
d2, err := ParseInterval(s2)
if err != nil || d2 != d {
t.Fatalf("round-trip broke: %q -> %v -> %q -> %v (%v)", s, d, s2, d2, err)
}
})
}Without -fuzz, go test just runs the seeds plus the committed corpus — cheap, deterministic, CI-friendly. With go test -fuzz=FuzzParseInterval, the engine mutates inputs coverage-guided: an input that reaches a new branch is kept and mutated further, so the search concentrates on code it has not seen, typically tens of thousands of execs per second per worker. The mechanism is why fuzzing finds what tables cannot: a table encodes examples its author imagined; the fuzzer explores the input space your code actually branches on — sign-without-digits, lone UTF-8 continuation bytes, 1e309. When an input fails, the engine minimizes it (shrinking while the failure reproduces) and writes it to testdata/fuzz/FuzzParseInterval/ — commit that file and the crash is a permanent regression test, run by ordinary go test with no flags. The craft is in the property: panics come free, but “no panic” is weak; round-trips (parse∘format = id), comparisons against a reference implementation, and invariants (output length, sortedness) are where fuzzing earns senior-grade value.
go test -fuzz found a crashing input for your parser and wrote a file under testdata/fuzz/FuzzParse/. What happens on the next plain go test run in CI, with no -fuzz flag?
Benchmarks: b.Loop, ns/op, allocs/op
When you read a benchmark result, how do you know you are looking at the cost of your function and not the cost of an optimized-away loop? That question has a precise answer — and it changed in Go 1.24.
A benchmark is BenchmarkXxx(b *testing.B), run via go test -bench=.. Since Go 1.24 the loop is b.Loop():
func BenchmarkParseInterval(b *testing.B) {
b.ReportAllocs()
input := strings.Repeat("1h30m", 1) // setup, outside the loop
for b.Loop() {
d, err := ParseInterval(input)
if err != nil {
b.Fatal(err)
}
_ = d
}
}b.Loop fixes two chronic lies of the older for i := 0; i < b.N; i++ style. First, dead-code elimination: the compiler may notice a result is never used and delete the call — old benchmarks needed package-level sink variables to defeat it; b.Loop keeps the loop body’s arguments and results alive automatically. A benchmark reporting 0.25 ns/op is reporting the cost of an empty loop, not your function. Second, setup amortization: the b.N style re-ran the whole function with growing N (1, 100, 10000…) until the run exceeded -benchtime (default 1 s), re-executing setup each round unless you remembered b.ResetTimer; b.Loop runs setup exactly once and times only the loop. The numbers: ns/op is wall time per iteration; -benchmem or b.ReportAllocs() adds B/op and allocs/op — allocation counts are exact (from the runtime’s allocator counters) and usually the most stable, most actionable number a benchmark produces: cutting allocs/op from 7 to 1 is a real win regardless of timer noise.
benchstat: statistical honesty
A single before/after run proving your optimization made things “4% faster” proves nothing. Wall-clock benchmarks on real machines wobble — thermal throttling, core migration, cache and ASLR layout, a browser tab — easily ±2–5% on a laptop, worse on shared CI runners. The honest protocol: run each side ~10 times and let benchstat do the statistics:
// go test -bench=ParseInterval -count=10 > old.txt
// ... apply the change ...
// go test -bench=ParseInterval -count=10 > new.txt
// benchstat old.txt new.txt
//
// │ old.txt │ new.txt │
// ParseInterval 412.5n ± 2% 369.0n ± 1% -10.55% (p=0.000 n=10)
// (an insignificant change prints: ~ , p=0.483)benchstat reports the median, the spread, and a p-value from a Mann-Whitney U test; if the distributions are statistically indistinguishable it prints ~ instead of a delta. That tilde is the feature: it stops you from shipping noise as a win. Discipline that keeps the numbers real: quiet machine or dedicated runner, -count=10, compare allocs/op first (exact), and benchmark the level users feel — a 30% win in a function that is 0.1% of the profile is a rounding error, which is why benchmarks pair with pprof, not replace it.
An old-style benchmark loop runs sum := compute(x) but never uses sum, and reports 0.3 ns/op. What is actually being measured?
- 01Walk through what happens from go test -fuzz=FuzzParse to a committed regression test, and explain why fuzzing finds bugs that a 31-row table missed.
- 02Your teammate posts a single before/after benchmark run showing -4% ns/op. What is wrong with the claim and what is the honest protocol?
Go’s native fuzzing turns a property function into a search: f.Add seeds the corpus, the engine mutates inputs with coverage feedback so effort concentrates on unexplored branches, and any failing input is minimized and written into testdata/fuzz where, once committed, plain go test replays it forever — the crasher becomes the regression test. The value over tables is structural, not incremental: tables hold inputs an author imagined; the fuzzer finds the ”+.” that panicked a two-year-old parser in eleven seconds because no human writes that row. Strong targets assert more than survival — round-trips, reference parity, invariants. Benchmarks earn the same skepticism. b.Loop in Go 1.24 closes the two classic holes of the b.N style: dead-code elimination that turned unused results into 0.3 ns/op fantasies, and setup re-executed across the auto-scaling rounds unless b.ResetTimer was remembered. Read allocs/op (exact, from allocator counters) before ns/op (noisy, from the wall clock), and never compare single runs: -count=10 per side and benchstat, whose Mann-Whitney p-value prints a tilde for statistically indistinguishable results — the tilde that keeps a 4%-faster story out of the changelog when it is actually thermal noise. Fuzzing guards correctness on inputs you never wrote; benchstat guards honesty on numbers you want to believe. Now when you see a benchmark reporting 0.3 ns/op or a teammate posting a single before/after delta, you will know exactly which questions to ask before trusting the number.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.