go-native-fuzzing-go-test-corpus-seeding

Why Go's Native Fuzzer Is Worth A Second Look
I'll tell you when I finally got fuzzing. It wasn't the first time I ran go test -fuzz. It was the third time — after the engine spat out an input that crashed a JSON parser I'd been shipping for a year, and I realized the bug had been sitting there the entire time, one comma away from production. That moment changed how I think about tests.
Go shipped a native fuzzing engine in the standard testing package back in Go 1.18, and yet a surprising amount of production Go code still has zero fuzz targets. Part of the reason is cultural: Go programmers lean on table-driven unit tests, and table-driven tests feel like they cover "enough." Part of the reason is mechanical: people try go test -fuzz once, see it generate inputs that look like random garbage, and never come back. Both reactions miss the point. The most interesting moment in a fuzzing workflow isn't when the engine starts generating random bytes; it's the moment before that, when you decide what to put into the corpus. That moment is called seeding, and it's the single highest-leverage decision you make as the author of a fuzz target.
This is an intro-level tour of how Go's built-in fuzzer handles its corpus and what "seeding" actually means inside go test. My goal isn't to make you a fuzzing expert; it's to give you the mental model so the next time you write a parser, a deserializer, or anything that takes untrusted bytes, you reach for FuzzXxx the same way you reach for TestXxx.
What "Corpus" Means Inside go test
Why does go test -fuzz keep two corpora instead of one, and why does that detail end up mattering more than the mutation algorithm itself? When the Go team integrated fuzzing into go test, they made a deliberate decision to treat the corpus as a first-class artifact rather than an ephemeral cache. The corpus is the collection of inputs the fuzzer is allowed to feed your function. There are two distinct corpora, and conflating them is the most common beginner mistake I see.
The first is the seed corpus. The seed corpus is hand-curated and lives with your source code. It's the set of inputs you, the developer, decided are interesting enough to be tried on every single go test -fuzz run, on every developer machine, on every continuous-integration node. Seed corpus entries come from two places: calls to f.Add(...) inside the fuzz function, and files on disk under a special directory next to your test file.
The second is the generated corpus. The generated corpus is the cache the fuzzing engine builds as it runs. Whenever the engine finds an input that exercises a new code path (measured via coverage instrumentation), it tucks that input away so it can mutate from it later. Those entries live in a per-user cache directory, not in your repository, and your collaborators don't see them unless something special happens.
The "something special" is the failure path. When the fuzzer crashes on an input, it copies that crashing input out of the generated cache and into your repository under the seed-corpus directory, in a textual format with a hash-named file. That promotion-on-failure is what turns fuzzing from an open-ended search into a regression-test pipeline: once an input has hurt you, it becomes a permanent member of the seed corpus and is replayed on every future go test run, fuzz mode or not.
The official explainer is on the Go website at go.dev/doc/fuzz, and it's genuinely worth reading top to bottom before you write your first fuzz target.
The Two Ways To Seed: f.Add And testdata/fuzz
Last year a colleague spent an afternoon debugging why his fuzz target kept rejecting seeds at registration time, and the answer was a single mismatched argument type between f.Add and f.Fuzz — the kind of mistake the Go fuzzer is deliberately strict about. There are exactly two mechanisms for putting an input into the seed corpus, and which one you reach for depends on whether the input is small and obvious, or large and opaque.
The first mechanism is f.Add(args...) inside the FuzzXxx function body. This is the right answer for short, human-readable seeds: a handful of representative inputs that demonstrate the function's contract. The arguments to f.Add must match, in number and type, the parameters of the function passed to f.Fuzz(...). The Go fuzzer is strict about this; mismatches don't silently coerce, they fail at registration time.
func FuzzNormalizePath(f *testing.F) {
// Seed corpus: a handful of paths that exercise distinct branches.
f.Add("/usr/local/bin")
f.Add("./relative/path")
f.Add("") // empty
f.Add("/a/../b") // dot-dot resolution
f.Add("//double//slash") // collapse
f.Fuzz(func(t *testing.T, in string) {
out := NormalizePath(in)
if strings.Contains(out, "//") {
t.Errorf("double slash survived: %q -> %q", in, out)
}
})
}
Those five f.Add calls are the seed corpus in code form. Every time someone runs go test on this package, all five get evaluated against the property assertion inside f.Fuzz. When the same package runs with go test -fuzz=FuzzNormalizePath, those same five become the starting population for the mutation engine.
The second mechanism is the on-disk testdata directory. Next to the test file, you create the path testdata/fuzz/FuzzNormalizePath/ and drop files into it. Each file is a single seed, encoded in a tiny self-describing format the Go fuzzer defined for the purpose. The format starts with a go test fuzz v1 header, then one line per argument, each prefixed with the Go type of the argument. The on-disk format is documented in the package reference at pkg.go.dev/testing under the heading "Fuzzing."
The reason there are two mechanisms is that they serve different lifecycles. f.Add is appropriate when the seed is small enough that putting it in code reads naturally. The testdata directory is appropriate when the seed is too large or too binary to embed in a Go literal, and when the seed was discovered rather than designed — which brings us to the failure path.
Why The Failure Path Is The Point
Most write-ups of Go fuzzing focus on the mutation engine, but the mutation engine is the least interesting part — the round-trip between the generated cache and the seed corpus is where the design actually pays off. You can find more aggressive mutation engines in the broader fuzzing literature. The interesting part is the round-trip between the generated cache and the seed corpus.
Here's the workflow as it actually plays out on a developer's machine. You write a fuzz target with three or four f.Add calls. You run go test -fuzz=FuzzXxx. The fuzzer starts mutating from your seeds. After thirty seconds, or three minutes, or three hours, the engine finds an input that panics, or fails an assertion, or makes the function return an answer that violates an invariant you encoded. The engine stops. It writes a failure-on-XXXX line to standard output, and it copies the offending input out of its generated cache into testdata/fuzz/FuzzXxx/ as a new file with a hash-derived name.
From that moment forward, that input is a permanent member of the seed corpus. You commit the new file to version control. The next time anyone — you, a collaborator, a CI runner — runs plain go test on the package, the input is replayed. If the bug is still there, go test fails. If you fix the bug, go test passes. The fuzzer just authored a regression test for you and filed it on disk in the place where regression tests belong.
This loop is the entire reason the Go team integrated fuzzing into go test rather than shipping a separate go fuzz binary. The corpus isn't a side effect; the corpus is the artifact.
What Makes A Good Seed
The quality of the seed corpus determines the quality of the fuzzing campaign in a very direct way. The mutation engine works by taking an existing input and applying small changes to it: bit flips, byte insertions, slice deletions, integer increments, type-specific perturbations. If the seed corpus doesn't already exercise diverse code paths, the mutation engine has a hard time finding new ones, because most random mutations of a single seed land in the same handful of branches.
The practical advice is to pick seeds that are minimal, diverse, and representative.
Minimal means short. A 4 KB seed mutates poorly because the engine has too many byte positions to consider and most of them lead nowhere. A 12-byte seed mutates well because the engine can explore the local space exhaustively in seconds.
Diverse means the seeds should collectively exercise different branches in your function. If you're fuzzing a path normalizer, don't seed five variations of "/usr/local/bin"; seed one absolute path, one relative path, one empty string, one path with .., one path with redundant separators, one path with non-ASCII bytes. Each seed should be the simplest possible input that reaches a distinct code path.
Representative means the seeds should look like the real traffic the function will see in production, at least loosely. If your JSON parser is going to be fed configuration files, seed it with small valid configuration documents, not with arbitrary JSON pulled from a test fixture in another package.
One useful technique for assembling a diverse seed corpus quickly is to drain your existing table-driven unit tests. Most Go packages already have a TestXxx function with a table of inputs the author believed were interesting. Those inputs are, by definition, the result of someone thinking about the function's edge cases. Lifting the input column of the table into f.Add calls inside a FuzzXxx function is often the fastest way to bootstrap a seed corpus that actually exercises the function's branch coverage.
A Minimum Workflow Sketch
If you've never run native Go fuzzing before, the smallest possible end-to-end workflow looks like this. Pick a function in your code that takes untrusted bytes — a parser, a deserializer, a URL handler, a regex compiler wrapper. Add a FuzzXxx test next to its existing TestXxx test. Migrate three to five of the most-distinct inputs from the existing table into f.Add calls. Add a property assertion inside f.Fuzz: at minimum, "does not panic," but ideally something the function actually promises, like "the output is a valid UTF-8 string" or "normalizing twice equals normalizing once." Then run go test -fuzz=FuzzXxx -fuzztime=30s and see what happens.
If nothing crashes in thirty seconds, that's fine; commit the target and let CI pick up the seed corpus going forward. If something does crash, the engine will write the offending input into testdata/fuzz/FuzzXxx/. Read it. Understand it. Fix the bug. Commit both the fix and the new seed file. You now have a regression test you wouldn't have thought to write by hand.
Where To Go Next
Native fuzzing in Go is a deliberately shallow surface area. There's no fuzzing DSL, no dictionary files, no sanitizer integration in the standard tooling. The Go team made the tradeoff that the corpus model and the go test integration were the parts worth standardizing, and that anything more elaborate could live in third-party tools or in the larger OSS-Fuzz infrastructure.
That surface area is enough to be genuinely useful. The standard library itself uses native fuzzing on dozens of parsers and codecs, and the Go security team publishes a periodic accounting of bugs discovered this way. The next steps after writing your first target: read the official tutorial linked above end to end, look at how encoding/json and net/url use FuzzXxx in their own test files, and start treating seed-corpus files in testdata/fuzz/ as part of your test suite proper — reviewed in code review, kept under version control, and pruned when they become redundant.
The corpus is the artifact. Seed it deliberately, commit the crashes the fuzzer finds, and over time the directory turns into the most honest documentation your parser will ever have: the catalog of every input that has ever broken it.