Fuzz Testing in Go From Your First Fuzz Target to CI
Learn fuzz testing in Go: write a fuzz target, run go test -fuzz, read the failure, keep the saved input as a regression test and run fuzzing in CI.

Every test you write is limited by what you thought of. Fuzzing removes that limit for one kind of bug: the input you did not imagine. You describe a rule that must hold for any input, and Go's fuzzing engine generates inputs, runs your code, watches which branches each input reaches and keeps mutating the ones that reach new ones. It stops when it finds an input that breaks the rule, and it saves that input so the failure can be replayed.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Fuzzing has been part of go test since Go 1.18. This guide walks through one complete cycle on Go 1.25.6: a function that looks right, a fuzz target that proves it is not, the saved input that turns into a regression test, the fixed version, and how to run all of it in CI. Later articles go deeper on fuzz targets for parsers and on what fuzzing finds in real code. This one is the base they build on.
A function that looks right
package main
// ReverseBytes reverses by byte, which looks right for ASCII.
func ReverseBytes(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)
}
// ReverseRunes reverses by rune.
func ReverseRunes(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)
}
func main() {}ReverseBytes reverses a string by swapping bytes from both ends. Every example you would write works: "hello" becomes "olleh", the empty string stays empty, a palindrome stays the same. It is wrong for any string that contains a character outside ASCII, because UTF-8 encodes those as two to four bytes and reversing the bytes reverses the inside of the character. ReverseRunes converts to runes first, which is the fix.
Nothing about this is hard to see once someone points at it. The point is how a test would have found it without anyone pointing.
A fuzz target
package main
import (
"testing"
"unicode/utf8"
)
func FuzzReverseBytes(f *testing.F) {
f.Add("hello")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
if got := ReverseBytes(ReverseBytes(s)); got != s {
t.Errorf("double reverse of %q is %q", s, got)
}
if utf8.ValidString(s) && !utf8.ValidString(ReverseBytes(s)) {
t.Errorf("valid input %q gave invalid output %q", s, ReverseBytes(s))
}
})
}
func FuzzReverseRunes(f *testing.F) {
f.Add("hello")
f.Add("héllo")
f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
t.Skip("the property is about valid UTF-8")
}
rev := ReverseRunes(s)
if !utf8.ValidString(rev) {
t.Errorf("valid input %q gave invalid output %q", s, rev)
}
if ReverseRunes(rev) != s {
t.Errorf("double reverse of %q is %q", s, ReverseRunes(rev))
}
})
}A fuzz target is a function named FuzzXxx in a _test.go file, taking a *testing.F. It has two parts.
The seed corpus. f.Add("hello") gives the engine starting inputs. The seeds run on every plain go test, with no -fuzz flag, so the target is a table test as well. Good seeds are small examples of the valid input, plus the odd cases you already know about. The engine mutates seeds, so more varied seeds lead to broader exploration.
The fuzz function. f.Fuzz(func(t *testing.T, s string) {...}) is called with generated values. Its parameters after t must be of the supported types: string, []byte, the integer and float types, bool, rune and byte. You cannot fuzz a struct directly; take the primitives and build the struct inside.
The body is a property. You cannot write want := ... because you do not know the input in advance, so you state something true of every input. The target above checks two. Reversing twice must give back the original, and a valid UTF-8 string must reverse to a valid UTF-8 string. The second is the one that matters, and it takes the guard utf8.ValidString(s) first: the property is about valid input, and checking it on garbage input would report failures nobody cares about. The practical catalogue of properties, from round trips to oracles, is in the property based testing article.
Run it
rm -rf testdata
F='s/[0-9a-f]{16}/<hash>/; s/^(fuzz: elapsed: )[0-9a-z]+, execs: [0-9]+.*/\1.../; s/ \([0-9.]+s\)//; s/[0-9.]+s$/<time>/'
echo "# fuzz the byte version"
go test -run='^$' -fuzz=FuzzReverseBytes -fuzztime=10s . 2>&1 | grep -v '^fuzz: elapsed' | sed -E "$F"
echo
echo "# the saved input is now a regression test"
ls testdata/fuzz/FuzzReverseBytes | wc -l | tr -d ' ' | sed 's/$/ file under testdata\/fuzz\/FuzzReverseBytes/'
go test -count=1 -run=FuzzReverseBytes . 2>&1 | head -4 | sed -E "$F"
echo
echo "# fuzz the rune version"
go test -run='^$' -fuzz=FuzzReverseRunes -fuzztime=10s . 2>&1 | grep -v '^fuzz: elapsed' | sed -E "$F"# fuzz the byte version
fuzz: minimizing 43-byte failing input file
--- FAIL: FuzzReverseBytes
--- FAIL: FuzzReverseBytes
rev_test.go:16: valid input "\U0001c736" gave invalid output "\xb6\x9c\x9c\xf0"
Failing input written to testdata/fuzz/FuzzReverseBytes/<hash>
To re-run:
go test -run=FuzzReverseBytes/<hash>
FAIL
exit status 1
FAIL example.test/lab <time>
# the saved input is now a regression test
1 file under testdata/fuzz/FuzzReverseBytes
--- FAIL: FuzzReverseBytes
--- FAIL: FuzzReverseBytes/<hash>
rev_test.go:16: valid input "\U0001c736" gave invalid output "\xb6\x9c\x9c\xf0"
FAIL
# fuzz the rune version
PASS
ok example.test/lab <time>The first command is go test -run='^$' -fuzz=FuzzReverseBytes -fuzztime=10s. -fuzz takes a regular expression that must match exactly one fuzz target, so anchor it. -run='^$' skips ordinary tests so the time goes to fuzzing, and -fuzztime bounds the run; without it the engine runs until you press Ctrl-C or it finds a failure.
Read the failure from the top. The engine reports FAIL: FuzzReverseBytes, prints your own message with the failing value, and says where it saved the input: testdata/fuzz/FuzzReverseBytes/<hash>. It prints the command to replay that single case. The input it reports was shrunk, a step the engine calls minimizing, so you see a short value, usually the smallest string that still fails. A single multibyte character is enough here, and the output string is the same bytes in reverse order, which is no longer valid UTF-8.
The second block of output is the point of the file. The saved input is in testdata/fuzz, in a small text format that starts with go test fuzz v1, and a plain go test runs every file in that directory as a test case. The same failure appears with no fuzzing flag. Commit that directory. From then on the bug cannot return unnoticed, and the person who fixes it has a reproduction in the repository.
The third block runs the rune version. It ran for the same ten seconds and reported no failure. That means the engine did not find one in the time and with the executions it had, which is useful evidence and weaker than a proof. Longer runs and a richer corpus raise confidence, and they never reach certainty.
What the engine is doing
The engine is coverage guided. It instruments your code, so after each execution it knows which branches ran. An input that reaches a branch no earlier input reached is interesting and goes into the corpus; mutations of corpus entries (flipped bits, inserted bytes, spliced values) become the next inputs. That feedback loop is why fuzzing finds a bug behind a conditional in seconds when a random generator would need years: it learns that getting past the if is progress.
Two consequences follow. A target that runs fast finds more, so keep the fuzz function free of I/O, sleeps and allocation heavy setup; thousands of executions per second per worker is typical, and a target at ten per second is nearly useless. And a target with no branches to learn from, such as a function that hashes the input, gains nothing from guidance. Fuzz code that parses, decodes, validates or transforms.
By default go test -fuzz runs one worker per CPU core, controlled by -parallel. Interesting inputs it finds along the way are stored in the build cache, in the directory go env GOCACHE reports under fuzz, not in your repository, so a second run of the same target starts from where the first ended. Only failures are written to testdata/fuzz. To keep a good input as a permanent seed, copy it there or add it with f.Add.
The flags you will use
| Flag | What it does |
|---|---|
-fuzz=Regexp | Run the one fuzz target matching the pattern, with mutation. Anchor it, because two matches is an error |
-fuzztime=30s or -fuzztime=5000x | Stop after a duration or after a number of executions. Without it the run is unbounded |
-fuzzminimizetime=10s | Time spent shrinking a failing input. Set 0s to skip shrinking, which is useful when minimizing is slow |
-parallel=N | Number of fuzzing workers. The default is GOMAXPROCS |
-run='^$' | Skip ordinary tests so all the time goes to fuzzing |
-run=FuzzName/hash | Replay one saved input, which the failure message prints for you |
Two housekeeping commands matter. go clean -fuzzcache deletes the corpus the engine collected in the build cache, which you want when a target changed so much that old entries are noise. And go env GOCACHE shows where that cache lives, which you need when CI should save and restore it between runs. The corpus in the cache is an optimisation. The files in testdata/fuzz are the contract.
Reading a corpus file
A saved input is plain text and you can write one by hand. The first line is the version header, go test fuzz v1, and each following line is one argument, written as a Go value wrapped in its type: string("hello"), int(42), []byte("ab"), bool(true). A target with several parameters has one line per parameter, in order. That makes hand-made regression cases easy: when a user reports a crashing input, create testdata/fuzz/FuzzParse/issue-1234 with the header and one string(...) line, and the next go test runs it. The file name is only a label, so a descriptive one is better than a hash.
Because the format is text, the files diff well in review, and a reviewer can see exactly which input a fix is supposed to handle. That is a good reason to commit them and not to ignore the directory.
Choosing properties
The quality of a fuzz target is the quality of its property. Four kinds cover most cases.
- No panic. The weakest and often the most valuable: the function must not crash, hang or run out of memory for any input. An index out of range in a parser is the classic result. Calling the function and ignoring the return value is a legitimate fuzz target.
- Round trip.
Decode(Encode(x)) == x. Strong, general, and easy for encoders, serialisers and escapers. For decoders, start from the other end:Encode(Decode(s))only equalsswhensis in canonical form, so check that decoding the re-encoded value gives the same result. - Invariant. An output property: the result is sorted, valid UTF-8, no longer than the limit, with balanced brackets.
- Differential. Two implementations must agree: your fast parser and the standard library's, a new version and an old one. This finds the most subtle bugs and needs a reference.
Be careful with properties that are almost always true. If the fuzz function reports a failure on an input that is legitimately outside the function's contract, either constrain the input with t.Skip or document that the function accepts it. A target that fails for reasons the team accepts trains people to ignore the target.
Running it in a project
A fuzz target costs nothing in the normal build, and the investment is in where you run the fuzzing proper. A few arrangements work.
Every pull request. Plain go test ./... runs the seed corpus and the saved failures, in milliseconds. This is the regression layer and needs no setup.
A short fuzz run per change. Add a job that fuzzes the targets whose packages changed, for 30 to 60 seconds each. It catches the obvious bug an author has just introduced and costs a minute. The command is the same, with one target per run, since -fuzz allows only one:
go test -run='^$' -fuzz='^FuzzParse$' -fuzztime=30s ./internal/parseA longer scheduled run. A nightly or weekly job that gives each target 10 to 30 minutes and uploads any new testdata/fuzz files as artefacts. This is where the deep findings come from, and a failing nightly run should open an issue, not block a merge. Cache the fuzz corpus between runs: restoring the directory that go env GOCACHE reports under fuzz means each night builds on the previous one.
Continuous fuzzing services. OSS-Fuzz accepts Go projects and runs targets around the clock on Google's infrastructure, which suits open source libraries. For private code, the scheduled job is the practical equivalent.
Whatever the schedule, treat a failure as a bug report: read the saved input, write the fix, keep the file in testdata/fuzz and check that the target now passes.
Limits and costs
- Only one target per
-fuzzrun, and only one package. The pattern must match a single function in a single package. - Supported types are primitive. Complex inputs are built from them. For structured data, fuzz the serialised form and let your decoder build the object.
- It is not deterministic. Two runs find different things, which is why the failing input is saved: it is the only thing that makes a finding reproducible.
- It finds crashes and violated properties, not wrong answers. If the code returns a plausible but incorrect value and no property notices, fuzzing is silent.
- Instrumentation costs speed, and
-raceslows it further. Fuzz without the race detector for throughput, and run the corpus under-racein the normal tests. - Cgo and assembly code is not instrumented, so guidance does not see inside it.
The Go documentation has the fuzzing tutorial this example follows in spirit and the full reference, including the corpus format and the flags. The testing package documents the F type and its supported argument types.
What to do first
Pick the function in your code base that parses something an outsider controls: a config file, a header, a URL, a file format, a message. Write the shortest fuzz target that calls it and says nothing about the result, seed it with two realistic inputs, and run it for 30 seconds. If it finds nothing, add a round trip property and run it again. Most teams that try this on one parser find a panic in the first hour, and then it is easy to justify the second target.


