- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You wrote a small string decoder. It walks the bytes, handles a
couple of escape sequences, returns the cleaned-up result. You
wrote eight table-driven test cases. All green. You shipped.
Two weeks later a user pastes input that ends in a single
backslash, and the function panics on an index that your eight
cases never reached. The bug was always there. Your tests just
never guessed the one input that triggers it.
That guessing is the problem fuzzing removes. Instead of you
inventing inputs, the Go toolchain generates thousands of them,
mutates the ones that reach new code paths, and keeps going until
something crashes or fails an assertion. It has shipped inside the
standard go test command since Go 1.18. No external dependency,
no separate binary. If you have a Go toolchain, you have a fuzzer.
A function worth fuzzing
Fuzzing pays off on code that turns untrusted bytes into
structure: parsers, decoders, validators, anything that does index
math on input you did not create. Here is a small decoder that
turns backslash escapes into real bytes.
package escape
import (
"errors"
"strings"
)
var ErrMalformed = errors.New("malformed input")
// Unescape turns \n, \t and \\ into their byte values.
func Unescape(s string) (string, error) {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' {
b.WriteByte(s[i])
continue
}
switch s[i+1] { // read the escaped char
case 'n':
b.WriteByte('\n')
case 't':
b.WriteByte('\t')
case '\\':
b.WriteByte('\\')
default:
return "", ErrMalformed
}
i++
}
return b.String(), nil
}
Looks fine. It has an obvious bug we will let the fuzzer find on
its own, so pretend you have not spotted it yet.
Writing a fuzz target
A fuzz target is a function named FuzzXxx that takes
*testing.F. It lives in a _test.go file, same as any other
test. Inside, you call f.Fuzz with a callback. The callback's
first argument is *testing.T; the rest are the inputs the fuzzer
controls.
package escape
import "testing"
func FuzzUnescape(f *testing.F) {
f.Add("plain text")
f.Add(`a tab\there`)
f.Add(`ends with newline\n`)
f.Fuzz(func(t *testing.T, s string) {
out, err := Unescape(s)
if err != nil {
return // rejected input is a valid outcome
}
// Property: decoding never grows the input.
// Each escape eats two bytes and emits one.
if len(out) > len(s) {
t.Errorf("Unescape(%q) grew %d -> %d",
s, len(s), len(out))
}
})
}
Two things are happening. The f.Add calls register seed
inputs. The f.Fuzz callback runs on every input the fuzzer
throws at it.
The callback also fails automatically if the code under test
panics, which is the catch we are counting on here. On top of that
we assert a property: decoding must never produce more bytes than
it consumed. You are not checking that a specific input yields a
specific output. You are checking an invariant that holds across
all accepted inputs. That shift is the point. Table tests check
examples. Fuzz targets check properties.
Seed inputs and the corpus
The fuzzer does not start from random noise. It starts from a
corpus: a set of inputs it mutates to explore new code paths.
The corpus has two sources.
The first is your f.Add calls, the seed corpus. Pick inputs that
exercise the interesting branches: plain text, a valid escape, a
value near an edge. Good seeds give the mutator a head start. It
flips bytes, trims them, splices seeds together, and watches which
mutations reach code the previous ones did not.
The second source is the generated corpus on disk. When the
fuzzer finds an input that reaches a new branch, it saves it under
$GOCACHE/fuzz. Later runs reuse it, so coverage compounds across
sessions. You do not commit that cache; it is a working set, not a
source file.
There is a third location that matters more, and it appears the
moment something breaks.
Running the fuzzer
Plain go test runs a fuzz target once per seed and stops. That
is deliberate: your seeds become ordinary unit tests in CI, no
extra wiring. To actually fuzz, you pass -fuzz with a pattern
matching one target:
go test -fuzz=FuzzUnescape
It runs until you stop it or it finds a failure. While healthy,
the output climbs:
fuzz: elapsed: 3s, execs: 325492 (108497/sec), new interesting: 12
fuzz: elapsed: 6s, execs: 719811 (131439/sec), new interesting: 14
new interesting counts inputs that reached fresh coverage. When
it stops climbing, the fuzzer has mostly explored what your seeds
can reach. Cap a run with -fuzztime so it does not go forever:
go test -fuzz=FuzzUnescape -fuzztime=30s
Give it a moment on our decoder and it stops being healthy:
--- FAIL: FuzzUnescape (0.02s)
--- FAIL: FuzzUnescape (0.00s)
panic: runtime error: index out of range [1] with length 1
Failing input written to testdata/fuzz/FuzzUnescape/582e...
To re-run:
go test -run=FuzzUnescape/582e...
The fuzzer found an input ending in a lone backslash. When s[i]
is the last byte and it is \, the code reads s[i+1] past the
end of the string and panics. Your eight table cases all had a
character after every backslash, so none of them hit it. The
fuzzer mutated a seed until it did.
The failing input becomes a regression test
Here is the part that makes Go's fuzzer worth adopting over a
bolt-on tool. When a fuzz target fails, the toolchain writes the
offending input to testdata/fuzz/FuzzUnescape/ inside your
package. That directory is meant to be committed.
Any file under testdata/fuzz/<target>/ is loaded as a seed on
every future run, including plain go test with no -fuzz flag.
So the crasher the fuzzer just found becomes a permanent,
deterministic regression test that runs in CI forever, without you
writing a single new assertion.
The saved file is a small text format, one value per line with its
type:
go test fuzz v1
string("\\")
You do not hand-write these. The fuzzer produces them and shrinks
each one to the smallest input that still fails, so what lands in
testdata is the minimal reproducer, not the noisy original that
first tripped the bug.
Fix, re-run, keep the guard
The fix is a bounds check before the lookahead:
func Unescape(s string) (string, error) {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' {
b.WriteByte(s[i])
continue
}
if i+1 >= len(s) {
return "", ErrMalformed
}
switch s[i+1] {
case 'n':
b.WriteByte('\n')
case 't':
b.WriteByte('\t')
case '\\':
b.WriteByte('\\')
default:
return "", ErrMalformed
}
i++
}
return b.String(), nil
}
You commit the fix and the testdata/fuzz file together. Now
go test ./... replays the saved crasher as a seed on every run.
If a later refactor drops the bounds check, the build goes red on
the exact input that caught it the first time. That loop is the
whole workflow: fuzz, catch, commit the reproducer, then fix. The
corpus in testdata grows into a record of every edge case your
code has failed on.
What the fuzzer can and cannot take
f.Fuzz supports a fixed set of argument types: []byte,
string, the signed and unsigned integer types, float32,
float64, bool, and rune/byte. You can take several at once,
and every f.Add must match that exact signature:
f.Fuzz(func(t *testing.T, prefix string, n int, ok bool) {
// ...
})
No structs, no slices of structs, no maps. When you need richer
input, take a []byte and decode it yourself inside the callback,
or map integers onto choices. It is a real constraint, and it
pushes you toward fuzzing the byte-level boundary of your system,
which is where untrusted input actually enters.
Keep it honest at the boundary
Fuzz targets belong where bytes cross into your domain: the HTTP
body decoder, the config parser, the wire-format reader. That is
the same seam hexagonal architecture calls an adapter — the thin
layer that turns the outside world into your types. Put your fuzz
targets there and the rest of your code gets to trust its inputs.
The Complete Guide to Go Programming goes deep on testing, the
corpus format, and how the toolchain instruments coverage to drive
the mutator. Hexagonal Architecture in Go covers where that
untrusted-input boundary should sit, so the parsers you fuzz stay
small, isolated, and easy to prove correct. Both are part of the
Thinking in Go series.

Top comments (0)