Fuzz Testing in Go: Finding Edge Cases Your Table Tests Miss
A table test only checks the inputs you thought of. The bug in your parser may be hiding in the case you never imagined: an invalid byte sequence, an enormous key, or a boundary value that sends execution down an unexpected path.
Go’s fuzzing support, part of the standard toolchain since Go 1.18, helps close that gap. It generates inputs, uses coverage to guide its search, and keeps inputs that expand coverage. When it finds a failure, it attempts to minimize the input and writes it to testdata/fuzz/ so later go test runs exercise it as a regression case.
Here’s what you need to know to use it.
The shape of a Go fuzz test
A fuzz test has strict rules that the Go testing tools enforce:
- The function must be named
FuzzXxx, accept only a*testing.F, and return nothing. - It must live in a
_test.gofile. - It must contain exactly one call to
(*testing.F).Fuzz. - The function passed to
f.Fuzztakes a*testing.Tfirst, then the fuzzing arguments.
The fuzzing arguments are limited to a fixed set of types: string, []byte, the sized and unsized integer types (int, int8, int16, int32/rune, int64, and their unsigned counterparts including byte), float32, float64, and bool. No structs, no slices of anything other than bytes, no maps.
That restriction shapes how you write fuzz tests. If you want to fuzz a struct, you fuzz the bytes and decode them yourself.
Let’s fuzz something with a real invariant. Round-tripping is the classic: encode then decode should give you back what you started with.
package encoding
import (
"encoding/base64"
"testing"
)
func FuzzBase64RoundTrip(f *testing.F) {
// Seed corpus: interesting starting points for the mutator.
f.Add([]byte(""))
f.Add([]byte("hello"))
f.Add([]byte{0x00, 0xff, 0xfe})
f.Add([]byte("hello\xbd\xb2=\xbc ⌘"))
f.Fuzz(func(t *testing.T, in []byte) {
encoded := base64.StdEncoding.EncodeToString(in)
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("decoding our own output failed: %v", err)
}
if string(decoded) != string(in) {
t.Errorf("round trip mismatch: got %q, want %q", decoded, in)
}
})
}
The f.Add calls build the seed corpus. Those entries run every time you execute go test, fuzzing or not. They’re regular test cases that double as starting material for the mutator.
What makes a good seed corpus
The engine repeatedly makes random changes to corpus entries, then uses coverage to decide which inputs are worth keeping. A small set of representative inputs gives it useful places to start.
So: seed at least one valid, non-trivial input. For a config parser, that means a real config line, something the mutator can use as scaffolding to break. Add every input that ever crashed your code, because a corpus is also an archive of past mistakes. For numeric arguments, cover the boundaries you’d cover by hand anyway, 0, -1, math.MaxInt64. And keep entries small. Small inputs run faster, and speed directly determines how many mutations the engine gets through per second.
For binary seed data you don’t want to inline as Go code, drop files in testdata/fuzz/{FuzzTestName}/. If you already have raw binary samples, file2fuzz converts them into the corpus file format:
go install golang.org/x/tools/cmd/file2fuzz@latest
file2fuzz -o testdata/fuzz/FuzzParse ./samples/*.bin
Corpus files have a simple text format. The first line is a version marker, then one line per fuzzing argument:
go test fuzz v1
[]byte("hello\xbd\xb2=\xbc ⌘")
int64(572293)
Those values are literal Go expressions. You can copy them straight into an f.Add call or a regular unit test, which is handy when you want to promote a corpus entry into a named test case.
Running the fuzzer
By default, go test treats a fuzz test like a unit test. It runs the seed corpus entries and exits. No mutation, no generated inputs. This is what runs in CI.
To fuzz, pass -fuzz with a regex matching exactly one fuzz test:
go test -fuzz=FuzzBase64RoundTrip -fuzztime=30s
The -fuzztime flag matters. Without it, fuzzing runs until it finds a failure or you hit Ctrl+C. Fine locally, useless in CI. You can also pass an iteration count, like -fuzztime=100000x.
The output tells you whether the fuzzer is making progress:
fuzz: elapsed: 0s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 0s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
fuzz: elapsed: 6s, execs: 680218 (118402/sec), new interesting: 12 (total: 203)
Watch two numbers. The first is execs/sec. The useful rate depends on the work being tested and the machine running it, but a low rate is a reason to inspect the target. Avoid network calls, unnecessary file I/O, and sleeps so the engine can try more inputs.
The second is “new interesting”, meaning inputs that expanded coverage beyond the existing generated corpus. It commonly rises quickly and then tapers. A flat zero can simply mean the current corpus already covers the reachable paths, but it is also worth checking that the target reaches the code you intended to test.
The generated corpus, containing coverage-expanding inputs the engine kept, lives in $GOCACHE/fuzz, not your repo. The cache can persist across fuzzing runs; use go clean -fuzzcache when you deliberately want to remove it.
One caveat worth knowing before you draw conclusions from a run: Go’s fuzzing coverage instrumentation is currently supported on AMD64 and ARM64. On unsupported operating-system and architecture combinations, go test rejects the -fuzz flag rather than running an unguided fuzzing campaign.
Determinism and shared state
Your fuzz target runs in parallel across multiple workers, in nondeterministic order. That rules out two things.
Avoid mutable global state. If your target mutates a package-level variable, results can depend on execution order, and a failure that reproduces on one run may disappear on the next. Do not retain mutable inputs or other per-invocation state after the call returns; each invocation should be self-contained.
Nondeterminism breaks the whole feedback loop, not merely your ability to reproduce a bug. The engine attributes coverage to specific inputs. If the same input takes different paths on different runs, coverage guidance turns to noise and minimization can’t shrink a failure reliably.
Keep each invocation fast. A target that blocks, deadlocks, or performs slow external work can stall a worker or make a fuzzing campaign ineffective, while a fast target lets the engine explore far more inputs in the same time.
Decoding structured input from bytes
Since you can only fuzz primitives, structured targets need a decoding step. Treat the byte slice as a source of fields and bail out early when there isn’t enough data:
package parser
import (
"encoding/binary"
"testing"
)
// Record is the structured type we actually want to exercise.
type Record struct {
ID uint32
Name string
}
func FuzzRecordValidate(f *testing.F) {
f.Add([]byte{0, 0, 0, 1, 'a', 'b'})
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) < 4 {
// Not enough bytes to build a Record. Skip, don't fail.
t.Skip()
}
r := Record{
ID: binary.BigEndian.Uint32(data[:4]),
Name: string(data[4:]),
}
// Validate must never panic, whatever it's given.
if err := r.Validate(); err != nil {
// An error is a valid outcome. A panic is not.
return
}
// If Validate says it's good, Marshal must succeed.
if _, err := r.Marshal(); err != nil {
t.Errorf("Marshal failed on validated record %+v: %v", r, err)
}
})
}
Use t.Skip() for inputs that can’t be interpreted, not t.Fatal(). Skipped inputs are discarded without being recorded as failures. But be careful skipping short inputs: skip too aggressively and you throw away coverage feedback the engine could have used.
The part of that example worth copying is the assertion structure. An error return isn’t a bug. The bug is a panic, or a disagreement between two functions that are supposed to agree. Fuzzing works best when you can name a property and hold the code to it: these two implementations always produce the same answer, this function never panics, encode followed by decode is the identity.
From crash to regression test
This is the part that makes Go’s fuzzing genuinely useful day to day.
When the engine finds a failing input, it minimizes it, repeatedly trying smaller variants that still fail, then writes the result to your seed corpus directory:
Failing input written to testdata/fuzz/FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
To re-run:
go test -run=FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
FAIL
That file is in your repository, not the cache. It’s now part of the seed corpus, which means plain go test runs it. Your workflow becomes:
go test -fuzz=FuzzFoofinds a crash and writes the testdata file.go test -run=FuzzFoo/<hash>reproduces it in isolation. Fast, deterministic, debuggable.- Fix the bug.
go testpasses. Commit the fix and the testdata file together.
The testdata file is your regression test. No hand-written test case needed, and the minimized input is usually small enough to read at a glance. If you’d rather have a named test, open the corpus file and copy the literal values into an f.Add call or a table entry. The format is designed for that.
Minimization takes time. -fuzzminimizetime controls how long each attempt gets (default 60s). Setting -fuzzminimizetime=0 disables it, which is what you want when you’d rather see the raw failing input immediately.
Wiring fuzz tests into CI
Use two separate jobs, because they do different things.
The regular test job runs go test ./.... That executes every seed corpus entry, including every past failure, and finishes in normal test time. This is your regression suite.
A separate scheduled job does the actual searching:
go test -fuzz=FuzzRecordValidate -fuzztime=5m ./internal/parser
Run it nightly. If it finds something, it writes a testdata file in the job’s checkout. Upload that file as a CI artifact so it survives the runner, then commit it with the fix to lock in the regression. -fuzz takes a regex matching a single fuzz test, so you need one invocation per target. A small script that loops over your fuzz test names works fine.
If you want machine-readable output for CI tooling, go test -json covers fuzz runs too. We wrote about the JSON output changes in Go 1.24 if you’re building on top of that.
For open source Go projects, native fuzz tests are supported by OSS-Fuzz, which runs them continuously on Google’s infrastructure.
Where fuzzing pays off
Fuzzing earns its keep on functions that take untrusted or wide input:
- Parsers and decoders of any kind
- Anything doing byte-slice arithmetic or manual bounds handling
- Encode/decode pairs where round-tripping should be lossless
- Two implementations that should agree, a fast path and a reference path
- Normalization and sanitization functions, where equivalence classes are easy to get wrong
It pays off less on business logic with narrow input types, or anything whose behavior depends on external services. If a function takes a well-formed struct from a trusted caller, focused table tests may provide more value than a fuzz target.
Which leaves the testdata/fuzz directory as the real deliverable. It grows slowly, one crash at a time, and every file in it is an edge case nobody on your team thought of, pinned in place so nobody has to think of it again.