Go Fuzz Testing with go test -fuzz
Go 1.18+ has native fuzzing. Write FuzzXxx(f *testing.F) functions, add seed corpus with f.Add(), then run go test -fuzz=FuzzXxx. The fuzzer mutates inputs and reports panics or unexpected behavior. Reproduce failures with go test -run=FuzzXxx/testdata/fuzz/... after the crash file is saved.
Fuzzing is a testing technique that generates random, malformed, or unexpected inputs to find bugs that hand-written test cases miss. Go 1.18 introduced native fuzzing into the standard toolchain — no plugins, no external frameworks. For any code that parses user input, deserializes data, or processes untrusted bytes, fuzzing is the most reliable way to find crashes and unexpected panics before production does.
What Go's Native Fuzzing Provides
Before Go 1.18, Go developers used third-party tools like go-fuzz (dvyukov's corpus-based fuzzer). Native fuzzing in the standard library offers:
- Integration with
go test— fuzz targets live alongside unit tests in_test.gofiles - Corpus management — seed inputs in
testdata/fuzz/FuzzName/are version-controlled and run as unit tests by default - Coverage-guided mutation — the fuzzer instruments code coverage and prefers mutations that reach new branches
- Cross-platform — works on Linux, macOS, and Windows (though Linux is fastest for corpus-building)
- Automatic crash reproduction — failing inputs are saved to disk and can be replayed with
go test -run
The fuzzer is coverage-guided: it tracks which code branches each input exercises and mutates successful inputs to discover new coverage paths. This makes it far more effective than purely random input generation.
Writing a Fuzz Function
Fuzz functions follow a specific signature: FuzzXxx(f *testing.F). The *testing.F type provides two methods: f.Add() for seeding the corpus, and f.Fuzz() for the actual test body.
func FuzzParseURL(f *testing.F) {
// Seed corpus: inputs the fuzzer starts with and mutates
f.Add("https://example.com/path?key=value")
f.Add("http://localhost:8080")
f.Add("ftp://files.example.com")
f.Add("not-a-url")
f.Add("")
f.Fuzz(func(t *testing.T, input string) {
// Must not panic for any input
result, err := ParseURL(input)
if err != nil {
return // errors are acceptable; panics are not
}
// Invariant: a successfully parsed URL should round-trip
if result.String() == "" {
t.Errorf("ParseURL(%q) returned empty string without error", input)
}
})
}The inner function passed to f.Fuzz() receives a *testing.T and the fuzzed arguments. The fuzzer will call this function millions of times with mutated variants of your seed inputs. Any panic or t.Fatal/t.Error call causes the fuzzer to save the failing input and report it.
Supported Fuzz Argument Types
f.Add() and the fuzz function parameters must match. The supported types are:
string, []byte
int, int8, int16, int32, int64
uint, uint8, uint16, uint32, uint64
float32, float64
boolFor complex inputs, serialize to bytes and deserialize inside the fuzz body:
func FuzzDecodeProto(f *testing.F) {
f.Add([]byte{0x0a, 0x03, 0x66, 0x6f, 0x6f}) // "foo" in proto field 1
f.Fuzz(func(t *testing.T, data []byte) {
var msg MyProtoMessage
if err := proto.Unmarshal(data, &msg); err != nil {
return // malformed proto is expected; panic is not
}
// Re-encoding must succeed if decoding succeeded
if _, err := proto.Marshal(&msg); err != nil {
t.Errorf("re-encode failed after successful decode: %v", err)
}
})
}Seed Corpus and Corpus Entries
The seed corpus serves two purposes. First, it gives the fuzzer starting points to mutate. Good seeds should cover edge cases and typical inputs, not just happy paths. Second, seeds are run as unit tests by default — without -fuzz — so they verify correctness of the seed inputs on every go test run.
Store corpus files in testdata/fuzz/FuzzFunctionName/:
testdata/
fuzz/
FuzzParseURL/
seed1 ← plain text file containing one input
seed2
1a2b3c4d... ← auto-saved crash input (hex name)Each file contains a corpus entry in Go's wire format:
go test fuzz v1
string("https://example.com")You can also add corpus entries programmatically using f.Add(), which is the more common approach. Files in testdata/fuzz/ that are auto-generated by the fuzzer should be committed to version control — they represent bugs that were found and fixed, and they prevent regressions.
Running and Interpreting Fuzzer Output
# Run all fuzz targets as unit tests (uses seed corpus only)
go test ./...
# Start fuzzing a specific target
go test -fuzz=FuzzParseURL
# Fuzz for a fixed duration
go test -fuzz=FuzzParseURL -fuzztime=60s
# Fuzz with more parallelism
go test -fuzz=FuzzParseURL -parallel=8While running, the fuzzer prints status lines:
fuzz: elapsed: 3s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 6s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 9s, execs: 98391 (32796/sec), new interesting: 12 (total: 204)
fuzz: elapsed: 12s, execs: 196832 (32814/sec), new interesting: 18 (total: 210)execs— total function calls to the fuzz targetnew interesting— inputs that increased code coverage; higher is better early on, plateaus as coverage saturatesworkers— goroutines running in parallel
When the fuzzer finds a failure:
--- FAIL: FuzzParseURL (3.14s)
fuzzing process hung or crashed while running seed corpus
Failing input written to testdata/fuzz/FuzzParseURL/1a2b3c4d5e6f
To re-run:
go test -run=FuzzParseURL/1a2b3c4d5e6fFixing and Reproducing Fuzz-Found Bugs
Once a failing input is saved to testdata/fuzz/FuzzParseURL/1a2b3c4d5e6f, reproduce it without fuzzing:
go test -run=FuzzParseURL/1a2b3c4d5e6f -vThis runs the exact failing input deterministically. You can now debug it like any other failing test:
go test -run=FuzzParseURL/1a2b3c4d5e6f -v 2>&1 > /tmp/fuzz_fail.logRead the corpus file to understand what input triggered the failure:
cat testdata/fuzz/FuzzParseURL/1a2b3c4d5e6f
# go test fuzz v1
# string("http://\x00example.com")Here the fuzzer injected a null byte into the URL. The fix: validate or reject inputs containing null bytes before parsing. After fixing, verify:
go test -run=FuzzParseURL # runs all corpus files as unit testsThe formerly-failing input now becomes part of the permanent seed corpus, preventing regression.
Writing Effective Fuzz Targets
Test invariants, not specific outputs. The fuzzer cannot know what the correct output should be for random input. Instead, test properties that must hold for all inputs:
f.Fuzz(func(t *testing.T, data []byte) {
compressed, err := Compress(data)
if err != nil {
return
}
// Invariant: decompressing what we compressed must return the original
decompressed, err := Decompress(compressed)
if err != nil {
t.Errorf("Decompress failed after Compress: %v", err)
return
}
if !bytes.Equal(data, decompressed) {
t.Errorf("round-trip failed: input %x, got %x", data, decompressed)
}
})Never panic on bad input in production code. If your parser panics on malformed input, that is the bug. Fuzz tests expose this by throwing every conceivable byte sequence at your code.
Avoid time-based operations inside fuzz targets. The fuzzer runs millions of iterations; anything with a timeout or rate limit will interfere.
Test both encoding and decoding together. The round-trip pattern — encode then decode and compare — catches bugs in either direction that unit tests on known inputs won't reveal.
Integration with CI
Running a fuzzer for one second in CI (the default when no -fuzz flag is passed) just replays the seed corpus. This is fast and valuable — the corpus accumulates known-bad inputs over time.
To do longer fuzzing in CI, add a dedicated step:
- name: Fuzz tests
run: |
go test -fuzz=FuzzParseURL -fuzztime=30s ./pkg/parser/
go test -fuzz=FuzzDecodeProto -fuzztime=30s ./pkg/proto/Commit any new corpus files that appear after fuzzer runs in CI. They represent discovered edge cases.
Comparison with go-fuzz
If you have existing go-fuzz targets using Fuzz(data []byte) int, migration is straightforward. The native fuzzer does not use the return value convention (0 = uninteresting, 1 = interesting) — instead, use t.Skip() if you want to signal an uninteresting input:
// go-fuzz style (old)
func Fuzz(data []byte) int {
if _, err := Parse(data); err != nil {
return 0
}
return 1
}
// Native fuzzing style (Go 1.18+)
func FuzzParse(f *testing.F) {
f.Add([]byte("example input"))
f.Fuzz(func(t *testing.T, data []byte) {
if _, err := Parse(data); err != nil {
t.Skip() // or just return — either signals uninteresting
}
})
}Key Takeaways
Go's native fuzzer is production-ready and requires no external tooling. Write FuzzXxx functions, seed the corpus with representative inputs including edge cases, and run with -fuzz to discover crashes. Commit the corpus to version control so discovered bugs become permanent regression tests. For any code path that processes external input — URLs, JSON, binary protocols, file formats — fuzzing finds the inputs your test cases never imagined.