Most Go developers I talk to know fuzzing exists. They’ve seen go test -fuzz=. in a README somewhere, ran it for 30 seconds, got bored, and moved on. That’s a shame, because Go’s built-in fuzzer — shipped with 1.18, no extra dependencies — is genuinely powerful, and the barrier to doing it right is lower than you think.
The problem isn’t the tool. The problem is that the average fuzz target is useless: it fuzzes a function that can’t panic, over a type space that’s too narrow to exercise anything interesting, with a seed corpus of one empty string. That’s not fuzzing. That’s theater.
This guide is about writing fuzz targets that actually find bugs. Real bugs. The kind that would have reached production.
The official Go fuzzing docs live at https://go.dev/doc/fuzz/ and are worth reading. But they stop short of telling you how to think about a fuzz target. That’s what we’re covering here.
What Go’s fuzzer actually does
Before writing anything, you need the mental model. The Go fuzzer is a coverage-guided mutational fuzzer. It starts from a seed corpus — a set of inputs you provide — and mutates them in random-ish ways, guided by which mutations cause previously-uncovered code paths to execute. When a mutation causes a panic, t.Fatal, or a detected race condition, it saves that input to disk so you can reproduce it.
This is not random testing. It’s targeted exploration of your input space, steered by your binary’s own coverage data.
Two things follow from this:
- Your seed corpus is a navigation map. A bad corpus means the fuzzer starts in the wrong neighborhood and spends all its time exploring uninteresting territory.
- What you assert inside the fuzz function determines what "wrong" means. If you assert nothing, the fuzzer only finds panics — it misses logic bugs entirely.
Keep both points in mind throughout.
Anatomy of a fuzz target
Go fuzzing uses a single new type, *testing.F, and fuzz functions must be named FuzzXxx. Here’s the skeleton:
func FuzzParseRecord(f *testing.F) {
// Seed corpus — inputs the fuzzer starts from
f.Add([]byte("name=Alice,age=30,active=true"))
f.Add([]byte(""))
f.Add([]byte("name=,age=-1,active=false"))
// The actual fuzz function — called for every generated input
f.Fuzz(func(t *testing.T, data []byte) {
record, err := ParseRecord(data)
if err != nil {
// Errors are fine; panics and assertion failures are not
return
}
// Re-encode and verify round-trip stability
encoded := record.Encode()
record2, err := ParseRecord(encoded)
if err != nil {
t.Fatalf("round-trip produced unparseable output: %v\nOriginal: %q\nEncoded: %q",
err, data, encoded)
}
if !record.Equal(record2) {
t.Fatalf("round-trip data mismatch\nOriginal: %+v\nRound-tripped: %+v", record, record2)
}
})
}
Supported argument types for the fuzz function: string, []byte, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool. That’s it. If your function takes a struct, you serialize it — more on that shortly.
Choosing what to fuzz
Not every function is a good fuzz target. Prioritize:
- Parsers — anything that accepts external input: JSON, YAML, CSV, binary protocols, URL parsing, config file readers.
- Decoders/deserializers — especially anything that does length-prefixed reads, bit manipulation, or decodes untrusted content.
- Validation logic — input sanitizers, regex matchers, input normalizers.
- Cryptographic or checksum operations — hash functions, signature verification, encoding schemes like base64 or hex.
- State machines driven by input — lexers, query parsers, expression evaluators.
Boring but safe functions — pure math, trivial getters, logging — produce almost nothing. Spend your budget on functions that touch raw bytes or make structural decisions about input shape.
Building a sharp seed corpus
The seed corpus lives in testdata/fuzz/FuzzYourFunctionName/. Each file is one test case, in a specific text format Go expects. You can generate these files manually or let f.Add(...) populate them on first run.
Here’s the format a corpus file uses:
go test fuzz v1
[]byte("name=Alice,age=30,active=true")
You don’t usually write these by hand. Use f.Add(...) for everything and let Go manage the files — but you do want to commit these to source control so CI reproduces bugs without mutation.
What makes a corpus sharp:
Cover the interesting structural cases, not just the happy path.
// This is a weak corpus:
f.Add("valid input")
// This is a better corpus for a CSV-like parser:
f.Add("") // empty
f.Add("a") // single char
f.Add("a,b,c") // basic valid
f.Add("a,,c") // empty field
f.Add(`"quoted,field",b`) // quoted with delimiter inside
f.Add("a\nb") // embedded newline
f.Add(string([]byte{0x00, 0x01, 0x02})) // binary junk
f.Add(strings.Repeat("a", 10000)) // large input
f.Add("a\r\nb") // Windows line endings
The goal is to get the fuzzer into different branches of your code before mutation starts. If your corpus only hits the happy path, mutation from there is likely to produce mostly invalid inputs that your error handler rejects in line 3 — wasted cycles.
Use real-world samples. If you’re fuzzing a Markdown parser, throw in a few real Markdown documents. If you’re fuzzing a MIME parser, grab samples from https://www.iana.org/assignments/media-types/media-types.xhtml. Real-world data has structure that exercises code paths you’d never think to construct manually.
Extract failing cases from your existing unit tests. Every input you’ve already tested is a seed candidate. Run through your _test.go files and pull interesting inputs into f.Add(...).
Writing useful assertions
A fuzz function that only checks for panics is leaving bugs on the table. The best fuzz targets use one or more of these patterns:
Round-trip invariant
Parse → encode → parse again. The two parsed results must be equal. This catches asymmetry bugs that don’t manifest as panics.
f.Fuzz(func(t *testing.T, data []byte) {
v, err := Decode(data)
if err != nil {
return
}
encoded := Encode(v)
v2, err := Decode(encoded)
if err != nil {
t.Fatalf("re-decode failed: %v", err)
}
if !reflect.DeepEqual(v, v2) {
t.Fatalf("round trip mismatch: got %v, want %v", v2, v)
}
})
Differential testing
Run two implementations of the same logic and compare results. Great when you’re replacing a dependency or reimplementing an algorithm.
f.Fuzz(func(t *testing.T, query string, limit int) {
// Reference implementation (old, trusted)
ref, refErr := oldSearch(query, limit)
// New implementation under test
got, gotErr := newSearch(query, limit)
// Errors should agree on error/non-error
if (refErr != nil) != (gotErr != nil) {
t.Fatalf("error mismatch: old=%v new=%v", refErr, gotErr)
}
if refErr == nil && !equalResults(ref, got) {
t.Fatalf("result mismatch for query=%q limit=%d\nold: %v\nnew: %v",
query, limit, ref, got)
}
})
Invariant checking
Assert a property that must hold regardless of input.
f.Fuzz(func(t *testing.T, data []byte) {
tokens, err := Tokenize(data)
if err != nil {
return
}
// Invariant: Detokenize(Tokenize(x)) must reproduce exactly the original bytes
// that correspond to token spans
for _, tok := range tokens {
if tok.End < tok.Start {
t.Fatalf("token has inverted span: start=%d end=%d", tok.Start, tok.End)
}
if tok.End > len(data) {
t.Fatalf("token span out of bounds: end=%d len=%d", tok.End, len(data))
}
}
})
Panic recovery as signal, not suppression
If you’re fuzzing code where panics are expected for malformed input (e.g., you call recover() in your library), check that the panic message is one you expected, not an index-out-of-range you didn’t anticipate:
f.Fuzz(func(t *testing.T, data []byte) {
defer func() {
if r := recover(); r != nil {
// Only allow specific expected panics
msg := fmt.Sprint(r)
if !strings.HasPrefix(msg, "invalid input:") {
t.Fatalf("unexpected panic: %v", r)
}
}
}()
_ = UnsafeParse(data)
})
Fuzzing structs and multi-field inputs
Go’s fuzzer only accepts primitive types. When your function under test takes a struct, serialize it into []byte or individual fields.
Option 1: serialize to bytes
If your struct has a canonical serialization, use that. For example, fuzzing JSON unmarshaling:
func FuzzUnmarshalConfig(f *testing.F) {
f.Add([]byte(`{"timeout":30,"retries":3,"endpoint":"https://cd-linux.club"}`))
f.Add([]byte(`{}`))
f.Add([]byte(`null`))
f.Fuzz(func(t *testing.T, data []byte) {
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return
}
// Validate derived fields make sense
if cfg.Timeout < 0 {
t.Fatalf("negative timeout %d after unmarshal", cfg.Timeout)
}
})
}
Option 2: use multiple primitives as fields
func FuzzCreateOrder(f *testing.F) {
f.Add("Alice", 42, true, uint64(1000))
f.Fuzz(func(t *testing.T, user string, quantity int, express bool, productID uint64) {
order, err := CreateOrder(user, quantity, express, productID)
if err != nil {
return
}
if order.Total < 0 {
t.Fatalf("negative total for qty=%d", quantity)
}
})
}
The multi-primitive approach gives the fuzzer more independent axes to mutate, which often produces more interesting coverage.
Running the fuzzer
Local development:
# Run fuzz for 60 seconds
go test -fuzz=FuzzParseRecord -fuzztime=60s ./pkg/records/
# Run until a failure (no time limit — Ctrl+C to stop)
go test -fuzz=FuzzParseRecord ./pkg/records/
# Run only the seed corpus (no mutation) — fast, deterministic
go test -run=FuzzParseRecord ./pkg/records/
Enabling the race detector while fuzzing costs ~3–5x slowdown but catches data races that only manifest under mutation:
go test -race -fuzz=FuzzParseRecord ./pkg/records/
Parallel fuzzing across cores happens automatically — Go runs one worker per CPU by default. Tune with -parallel:
go test -fuzz=FuzzParseRecord -parallel=8 ./pkg/records/
CI integration
Never run -fuzz in CI with no time limit — it’ll hang your pipeline. The right CI strategy is:
- Commit corpus files found during local/long-running fuzzing to
testdata/fuzz/. - In CI, run fuzz targets as ordinary tests (
-run=FuzzXxx), which only exercises the committed corpus without mutation. - Optionally, run a short fuzzing pass (e.g., 30s) in a nightly job and commit any new corpus entries back.
# GitHub Actions example — deterministic corpus replay in PR checks
- name: Run fuzz corpus
run: go test -run=Fuzz ./...
# Nightly job — short fuzzing pass
- name: Fuzz nightly
run: |
go test -fuzz=FuzzParseRecord -fuzztime=120s ./pkg/records/
git add testdata/fuzz/
git diff --cached --quiet || git commit -m "chore: update fuzz corpus"
This gives you the best of both: fast deterministic checks on every PR, and ongoing corpus growth over time.
Gotchas
Gotcha: fuzzing too wide a type space. If your fuzz function accepts string but your real function only handles 7-bit ASCII with a specific structure, the fuzzer wastes most of its time on inputs that get rejected at the gate. Narrow the input by pre-filtering or by encoding your constraints into the seed corpus structure. Sometimes generating valid-structure bytes inside the fuzz function and mutating a "seed" integer is smarter than fuzzing raw bytes.
Gotcha: forgetting to commit corpus files. Found a great crash, fixed it, but didn’t commit the reproducer to testdata/fuzz/. Six months later someone reintroduces the bug. Corpus files are regression tests — commit them.
Gotcha: t.Error vs t.Fatal inside fuzz. Use t.Fatalf to stop the current iteration immediately. t.Errorf marks failure but lets execution continue, which can cause confusing secondary failures or panics from a state that’s already wrong.
Gotcha: side effects in fuzz functions. The fuzz function runs millions of times. Any mutation of global state, file writes, or network calls inside it will destroy your host or take forever. Keep fuzz functions pure: in, transform, assert, out.
Gotcha: non-determinism hides bugs. If your function has non-deterministic output (e.g., map iteration order, timestamps, random number generation), round-trip and differential assertions will produce false positives. Either seed your RNG with the fuzz input, or strip the non-deterministic fields before comparison.
Gotcha: corpus file format surprises. If you manually write corpus files, they must match Go’s format exactly: first line is go test fuzz v1, subsequent lines are typed literals. A stray blank line or wrong type name silently skips the file.
Production-ready practices
Integrate with OSS-Fuzz or fuzzbench for long-running campaigns. Local fuzzing is great for exploration, but a sustained 24/7 campaign on dedicated hardware finds deeper bugs. OSS-Fuzz supports Go natively — if your project is open source, submitting it there is free CPU time you’re leaving on the table.
Use f.Helper() in shared assertion helpers. If you extract assertion logic into a helper function called from the fuzz function, call t.Helper() inside it so failure line numbers point at the fuzz function, not the helper.
Limit input size explicitly for expensive operations. If parsing a 100MB blob would take 10 seconds and the fuzzer generates one every millisecond, you’ll get nowhere. Add a size guard at the top of your fuzz function:
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 64*1024 {
return // skip unrealistically large inputs
}
// ...
})
Profile coverage after a fuzzing session. Run go test -coverprofile=fuzz.out -run=FuzzXxx ./... after committing a new corpus and check which branches are still uncovered. Coverage gaps in parser code almost always mean your corpus is missing a structural case.
Write fuzz targets early, not as an afterthought. The best time to write a fuzz target is when you’re writing the parser or decoder itself. You already have the happy-path examples in your head, and adding f.Add(...) for each takes 30 seconds. Retrofitting fuzzing onto code with complex state assumptions is much harder.
Finding your first real bug
If you’ve never found a real bug with fuzzing, here’s a practical recipe:
- Pick any function in your codebase that parses a string or byte slice.
- Write a fuzz target with at least five diverse seed entries: empty, minimal valid, long valid, borderline invalid, and binary junk.
- Add a round-trip assertion if the type has serialization.
- Run for 10 minutes:
go test -fuzz=FuzzYourTarget -fuzztime=10m ./... - Check the output. If you see
FAIL, look intestdata/fuzz/FuzzYourTarget/for the saved input.
On production codebases, even a 10-minute run frequently surfaces an index-out-of-bounds, an off-by-one in length calculation, or a panic in an error path that unit tests never hit because unit tests are written by the same person who wrote the code — and they have the same blind spots.
That’s why fuzzing matters. It doesn’t share your assumptions.