Fuzz Testing in Go: Finding Bugs Automatically
Fuzz testing feeds your code millions of mutated inputs and flags any that break a property you defined. This tutorial shows you how to write a FuzzXxx target, run it with go test -fuzz, and watch the engine find two real bugs end to end: a string reverse that corrupts UTF-8 and a parser that panics. Every run and every output below is real, on Go 1.24.7.
Works with Go 1.18+, when fuzzing became part of the standard go test toolchain. No third-party libraries, no build tags.
What fuzzing finds that table tests cannot
A table test checks the inputs you thought of. You hand-pick the tricky cases you remember and assert the expected output. That is the problem: you only test the bugs you already imagined. The input that breaks your code is usually the one you never wrote down.
Fuzzing inverts that. Instead of hand-picked inputs and expected outputs, you give the engine a few seed inputs and a property: a statement that must hold for every possible input. The engine mutates those seeds, watches which mutations reach new code paths, and keeps evolving the promising ones. Native Go fuzzing is coverage-guided, the same idea behind libFuzzer and AFL, documented in the Go fuzzing reference. It is not random spraying: when a mutated input hits a branch it has not seen, the engine saves that input and mutates it further.
Fuzzing complements table tests, it does not replace them. Table tests pin exact input-to-output behavior; fuzzing hunts for the input that violates an invariant. You want both. This guide assumes you already write standard tests; if not, start with the testing pillar.
Writing a fuzz target: FuzzXxx, f.Add, f.Fuzz
A fuzz target lives in a _test.go file and follows three rules. The function name starts with Fuzz, it takes *testing.F, and it calls f.Fuzz exactly once with a fuzz function whose first parameter is *testing.T followed by the input arguments.
Here is a target for a string reverse function. We seed it with a few strings and assert two properties.
package fuzzdemo
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
seeds := []string{"hello", "Go", "racecar", ""}
for _, s := range seeds {
f.Add(s) // add each seed to the corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("double reverse changed value: orig=%q doubleRev=%q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("reverse produced invalid UTF-8: orig=%q rev=%q", orig, rev)
}
})
}
f.Add registers a seed input. Seeds do two jobs: they run on every normal go test, and they give the mutation engine a starting point. The types passed to f.Add must match the fuzz function parameters exactly, string here. Supported fuzz types are the built-in numerics, bool, string, and []byte; a target that needs a struct fuzzes a []byte and decodes it.
The two assertions are the properties: reversing twice must return the original (a round trip), and reversing valid UTF-8 must stay valid UTF-8. Now the function under test, written the naive way:
package fuzzdemo
// Reverse returns s with its characters in reverse order.
// This version reverses bytes, which is wrong for multibyte UTF-8.
func Reverse(s string) string {
b := []byte(s)
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
Reversing bytes looks fine and passes the seeds. A plain go test runs only the seed corpus, not the mutation engine, so it reports success:
ok fuzzdemo 0.002s
That green check is a trap. The seeds are all ASCII, where one byte equals one character. The bug lives outside the inputs you thought of.
Running the fuzzer and reading its output
To actually fuzz, add the -fuzz flag with a regex matching one target. Bound the run with -fuzztime so it stops instead of running forever:
go test -fuzz=FuzzReverse -fuzztime=30s
The engine ran for a fraction of a second before finding a counterexample:
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 2 workers
fuzz: elapsed: 0s, execs: 983 (19821/sec), new interesting: 4 (total: 8)
--- FAIL: FuzzReverse (0.05s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:20: reverse produced invalid UTF-8: orig="ۼ" rev="\xbc\xdb"
Failing input written to testdata/fuzz/FuzzReverse/10947366ec745be9
To re-run:
go test -run=FuzzReverse/10947366ec745be9
FAIL
exit status 1
FAIL fuzzdemo 0.053s
Read the status line. gathering baseline coverage runs every existing corpus entry once to map coverage. 2 workers means one process per CPU by default. execs is total executions; new interesting counts inputs that hit a new code path and were kept. The engine found "ۼ", a two-byte Arabic character (U+06FC). Reversing its bytes produces \xbc\xdb, which is not valid UTF-8. The property held for every ASCII seed and broke on the first multibyte character the mutator tried.
The saved corpus entry and reproducing the failure
The line Failing input written to testdata/fuzz/FuzzReverse/10947366ec745be9 is the part that makes Go fuzzing practical. The engine did not just print the bug, it wrote it to disk as a regression test:
go test fuzz v1
string("ۼ")
That file is committed to your repo. From now on, this exact input runs on every plain go test, because entries under testdata/fuzz are part of the seed corpus. The failure is captured forever, no external service, no flaky reproduction. Run the saved case directly:
go test -run=FuzzReverse/10947366ec745be9
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/10947366ec745be9 (0.00s)
reverse_test.go:20: reverse produced invalid UTF-8: orig="ۼ" rev="\xbc\xdb"
FAIL
A plain go test ./... now fails too, without any -fuzz flag, because the saved entry is a permanent case.
Fixing the bug, and the second bug the fix exposes
The fix is to reverse runes, not bytes. A []rune conversion decodes UTF-8 into code points, so multibyte characters move as a unit:
// Reverse returns s with its runes in reverse order.
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
The saved regression now passes:
go test -run=FuzzReverse/10947366ec745be9
PASS
ok fuzzdemo 0.004s
Fuzz again for 30 seconds and something instructive happens. The engine finds a different failure:
--- FAIL: FuzzReverse (0.02s)
reverse_test.go:17: double reverse changed value: orig="\xe1" doubleRev="�"
The input is a lone byte \xe1, which is not valid UTF-8. Converting it to []rune replaces it with the Unicode replacement character U+FFFD, so Reverse(Reverse("\xe1")) returns �, not the original byte. This is not a bug in the fix. It is a bug in the property. The round-trip guarantee only holds for valid UTF-8, because that is the only input []rune preserves. The correct move is to make the target skip invalid input for that property, exactly as the go.dev fuzzing tutorial does:
f.Fuzz(func(t *testing.T, orig string) {
if !utf8.ValidString(orig) {
t.Skip() // round trip is only defined for valid UTF-8
}
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("double reverse changed value: orig=%q doubleRev=%q", orig, doubleRev)
}
if !utf8.ValidString(rev) {
t.Errorf("reverse produced invalid UTF-8: orig=%q rev=%q", orig, rev)
}
})
Delete the bad corpus entry, then run a full 30 seconds clean:
fuzz: elapsed: 3s, execs: 88701 (29566/sec), new interesting: 21 (total: 30)
fuzz: elapsed: 30s, execs: 1564092 (48939/sec), new interesting: 26 (total: 35)
PASS
ok fuzzdemo 30.123s
Over 1.5 million inputs, no failure. That is the whole loop: a wrong function, a fuzzer-found input, a fix, a wrong property, a sharper property, then green. t.Skip is how you tell the engine an input is out of scope without failing.
Layer 3: fuzzing a real parser to a real fix
The reverse example is small on purpose. In production the property that pays off most is the simplest one: this parser must never panic on untrusted input. Anything that parses request headers, cookies, config lines, or user-supplied query strings is a target. Here is a parser for header-style parameters like v=1; path=/; name=abc, written the way it usually gets written first:
package fuzzdemo
import "strings"
// ParseParams parses a header value like "v=1; path=/; name=abc" into a map.
// First (buggy) version: it splits each pair on "=" and reads parts[1].
func ParseParams(header string) map[string]string {
out := make(map[string]string)
for _, pair := range strings.Split(header, ";") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
parts := strings.Split(pair, "=")
key := strings.TrimSpace(parts[0])
out[key] = strings.TrimSpace(parts[1]) // parts[1] may not exist
}
return out
}
The target needs no explicit assertion. A panic fails the fuzz function automatically, so calling the parser is the check:
func FuzzParseParams(f *testing.F) {
f.Add("v=1; path=/; name=abc")
f.Add("max-age=3600")
f.Add("charset=utf-8")
f.Fuzz(func(t *testing.T, header string) {
// The property: parsing untrusted header input must never panic.
ParseParams(header)
})
}
Every seed is well formed, so go test passes. Fuzzing does not:
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 2 workers
fuzz: minimizing 49-byte failing input file
--- FAIL: FuzzParseParams (0.02s)
testing.go:1693: panic: runtime error: index out of range [1] with length 1
The engine mutated a seed into a pair with no =, hit parts[1] on a one-element slice, and panicked. Notice minimizing 49-byte failing input file: after finding a crash, Go tries to shrink it to the smallest input that still fails. The saved entry is a single character:
go test fuzz v1
string("0")
"0" is minimal proof: one token, no =, index out of range. The fix uses SplitN to cap the split and treats a missing value as a flag attribute like cookies use (Secure, HttpOnly):
func ParseParams(header string) map[string]string {
out := make(map[string]string)
for _, pair := range strings.Split(header, ";") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
kv := strings.SplitN(pair, "=", 2)
key := strings.TrimSpace(kv[0])
if len(kv) == 1 {
out[key] = "" // flag attribute, no value
continue
}
out[key] = strings.TrimSpace(kv[1])
}
return out
}
The saved crash passes, and a fresh 15-second fuzz stays green through a million executions:
fuzz: elapsed: 15s, execs: 1004374 (73584/sec), new interesting: 96 (total: 100)
PASS
ok fuzzdemo 15.050s
This is the pattern worth reusing: parser plus “never panic” property plus a handful of valid seeds. If you write HTTP handlers, fuzz the code that reads anything a client controls. The JSON validation guide shows how to reject malformed input instead of letting bad data reach a panic.
The seed corpus versus the corpus cache
Two corpora exist and people confuse them. The seed corpus is your f.Add calls plus every file under testdata/fuzz/FuzzName/. It is committed to version control, runs on plain go test, and is how failures become permanent regression tests. The generated corpus is the cache the engine builds while fuzzing: interesting inputs it discovered, stored under $GOCACHE/fuzz. On this machine:
$ go env GOCACHE
/root/.cache/go-build
$ ls $GOCACHE/fuzz/fuzzdemo
FuzzParseParams
FuzzReverse
The cache is machine-local, not committed, and safe to delete with go clean -fuzzcache. It speeds up repeated runs because the engine resumes from inputs it already found interesting. When a failure occurs, only the minimized reproducer moves from the cache into testdata, so your repo stays small while the local cache can grow large.
Limiting fuzz runs in CI with -fuzztime
Fuzzing runs until it finds a failure or you stop it, so it does not belong in a normal CI test step unbounded. Two flags control the budget. -fuzztime takes a duration (30s, 5m) or an iteration count with an x suffix:
go test -fuzz=FuzzParseParams -fuzztime=500x
fuzz: elapsed: 0s, execs: 500 (12140/sec), new interesting: 0 (total: 100)
PASS
ok fuzzdemo 0.047s
The count form is deterministic across machines, which CI prefers. A common setup: run go test ./... on every pull request (this replays the seed and testdata corpus, catching known regressions in milliseconds, with no -fuzz), and run a longer go test -fuzz on a nightly schedule to hunt for new inputs. Only one target can run per -fuzz invocation, so nightly jobs loop over targets. Keep an eye on runtime alongside your benchmarks so the fuzz budget stays predictable.
What makes a good fuzz target
Three traits separate a target that finds bugs from one that wastes CPU.
It has a checkable property: a round trip (decode(encode(x)) == x), an invariant (output is always valid UTF-8), a comparison against a slow-but-correct reference, or the weakest useful one of all, “does not panic”. Without a property the fuzzer runs your code and checks nothing.
It is deterministic. The same input must produce the same result every time. If the function reads the clock, a random source, map iteration order, or a socket, the engine cannot reproduce a failure and the saved corpus entry is useless.
It has no external state. Good targets are pure functions of their input: parsers, encoders, validators, math. No database, no filesystem, no shared globals mutated across runs. That isolation is what lets the engine run a million iterations a second across parallel workers.
Common mistakes
Fuzzing without a property. f.Fuzz(func(t *testing.T, s string) { MyFunc(s) }) with no assertion only catches panics. That is a valid property, but if MyFunc returns a wrong answer silently, the fuzzer sails past it. Assert something real.
A non-deterministic target. Comparing output against time.Now() or a random seed produces failures that vanish on re-run. The engine writes a testdata entry that then passes, and you chase a ghost. Fuzz pure logic; keep clocks and randomness out.
Forgetting seeds. With no f.Add calls, the engine starts from nothing and takes far longer to reach interesting code. Seed with the shapes real input takes, including known edge cases. Seeds are also your fastest regression tests.
Running fuzz in the default test run. go test does not fuzz. It replays the corpus and exits. Developers add a FuzzXxx function, see it “pass” in CI, and assume they are fuzzing. You are only fuzzing when -fuzz is present. Everything else just runs the saved inputs.
Ignoring the saved corpus. When the engine writes a file to testdata/fuzz, commit it. That file is a reproducer for a real bug. Deleting it or leaving it untracked throws away the one artifact that proves the bug and guards against its return.
What next
You now have the full loop: write a FuzzXxx target, seed it, define a property, run go test -fuzz, and turn every failure into a committed regression test. Point it at any code that parses untrusted input.
- Ground your fuzzing in a complete testing workflow with the Go testing guide, the pillar for unit tests, table tests, and coverage.
- Compare the hand-picked approach in table-driven tests to see exactly where fuzzing takes over.
- New to Go or filling gaps in the fundamentals? The complete Go tutorial covers the language from the ground up.
- Practice rejecting malformed structured input with the patterns in JSON encoding, decoding, and validation.