Fuzzing finds the input you did not write¶
Level: 301 · anyone whose table of cases is only the inputs they thought of
One line: A func FuzzParse(f *testing.F) run by plain go test executes only its seed corpus — the f.Add calls and the files under testdata/fuzz/FuzzParse/ — and is as deterministic as any test; go test -fuzz=FuzzParse mutates those seeds until something fails and writes the failing input to that folder, where it stays a seed for good; and a corpus file written by hand in the same two-line format pins an input the fuzzer never has to find again.
sku.Parse splits "WH-1234" at its dash, and carries a deliberate bug: a SKU that starts with XX panics. The fuzz target states a property rather than a case — whatever Parse accepts must put back together into the input — and seeds it with three values. The driver runs the seed corpus, then writes a corpus file by hand for the crashing prefix and runs it again.
Verified output of fuzzing_seed_corpus_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go test -v -run=FuzzParse
=== RUN FuzzParse
=== RUN FuzzParse/seed#0
=== RUN FuzzParse/seed#1
=== RUN FuzzParse/seed#2
--- PASS: FuzzParse
--- PASS: FuzzParse/seed#0
--- PASS: FuzzParse/seed#1
--- PASS: FuzzParse/seed#2
PASS
ok example
exit status 0
$ mkdir -p testdata/fuzz/FuzzParse
$ printf 'go test fuzz v1\nstring("XX-9")\n' >testdata/fuzz/FuzzParse/legacy_prefix
$ go test -v -run=FuzzParse
=== RUN FuzzParse
=== RUN FuzzParse/seed#0
=== RUN FuzzParse/seed#1
=== RUN FuzzParse/seed#2
=== RUN FuzzParse/legacy_prefix
--- FAIL: FuzzParse
--- PASS: FuzzParse/seed#0
--- PASS: FuzzParse/seed#1
--- PASS: FuzzParse/seed#2
--- FAIL: FuzzParse/legacy_prefix
panic: unhandled legacy prefix XX [recovered, repanicked]
exit status 2
FAIL example
exit status 1
$ go test -run=FuzzParse/legacy_prefix
--- FAIL: FuzzParse
--- FAIL: FuzzParse/legacy_prefix
panic: unhandled legacy prefix XX [recovered, repanicked]
exit status 2
FAIL example
exit status 1
Reading the output¶
Without -fuzz, the seeds are the test. FuzzParse/seed#0, seed#1, seed#2 — one subtest per f.Add, in order, all passing, and ok. The testing package documentation states it plainly: without the -fuzz flag, the fuzz function is called with the seed corpus only, and a fuzz test then behaves like an ordinary test (testing, "Fuzzing" ↗). That is the key this page can hold: it does not depend on a random search, so it runs in CI on every commit, on both machines, and it is cached like any test.
A corpus file is two lines. go test fuzz v1 and then one line per argument, string("XX-9"), in the format the fuzzer writes for itself (Go Fuzzing, "Corpus file format" ↗). The file's name is the subtest's name — FuzzParse/legacy_prefix — so a hand-written one can say what it is for, where a found one is a hash. After it exists, the seed run fails on it: --- FAIL: FuzzParse/legacy_prefix, the panic: line, and the test binary's exit status 2, because a panic in a test ends the binary (the errors page). Fix the bug and the entry stays as the regression test for it.
-run=FuzzParse/legacy_prefix runs one entry. The same -run matching per level as for any subtest — A table case is a subtest with t.Run — which is how go test tells you to reproduce a failure it found: go test -run=FuzzParse/<hash>.
What -fuzz finds¶
The search itself cannot be a key: how many workers it starts, how long it takes, and which input it minimizes to differ between runs. One run, from demo/fuzz_run.sh:
$ go test -run=FuzzParse -fuzz=FuzzParse -fuzztime=10s
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 12 workers
fuzz: minimizing 46-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzParse (0.26s)
testing.go:1825: panic: unhandled legacy prefix XX
Failing input written to testdata/fuzz/FuzzParse/69cf3d20e8d01ab7
To re-run:
go test -run=FuzzParse/69cf3d20e8d01ab7
FAIL
exit status 1
FAIL example 1.544s
exit status 1
$ cat testdata/fuzz/FuzzParse/69cf3d20e8d01ab7
go test fuzz v1
string("XX")
The engine first runs the seeds to measure their coverage, then mutates them on one worker per GOMAXPROCS until a call fails, then minimizes the input — 46 bytes down to "XX" — and writes it under testdata/fuzz/FuzzParse/ with a name derived from the input, so that the same minimal input gets the same name on the next machine. A second run on the same day found the same two bytes from a 56-byte input in 0.16 s; on a slower bug the times and the found inputs vary more. -fuzztime bounds the search; without it, fuzzing runs until something fails or it is interrupted (Go Fuzzing, "Custom settings" ↗). Run bash demo/fuzz_run.sh from this folder for one run of your own, or bash demo/fuzz_run.sh 30 for a longer search.
What f.Fuzz accepts. The fuzz function's first parameter is *testing.T, and the others are the types the engine can mutate: string, []byte, bool, the integer types and the two float types (testing, "Fuzzing" ↗) — a struct is refused, with the message on the errors page. Anything richer is decoded from those inside the target.
Where it runs. Coverage-guided fuzzing needs instrumentation the toolchain has on AMD64 and ARM64 (Go Fuzzing, "Requirements and limitations" ↗) — both CI machines of this library, but not every architecture. The seed-corpus run has no such requirement, and no cgo is involved either way.
fuzzing_seed_corpus_sh.sh
fuzzing_seed_corpus_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# A fuzz target run without -fuzz runs only its seed corpus: the f.Add calls
# and the files under testdata/fuzz/<target>/. That run is an ordinary test,
# deterministic and cached like any other. A corpus file written by hand, in
# the format the fuzzer itself writes, makes a crashing input part of the
# seed corpus for good. The panic's trace holds addresses, so only the
# "panic:" line is kept.
set -u
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
export GOTOOLCHAIN=local
cat >sku.go <<'GO'
// Package sku parses stock-keeping units of the form "WH-1234".
package sku
import (
"errors"
"strings"
)
// Parse splits a SKU into its warehouse code and its number.
func Parse(s string) (warehouse, number string, err error) {
if strings.HasPrefix(s, "XX") {
panic("unhandled legacy prefix XX") // the deliberate bug
}
i := strings.IndexByte(s, '-')
if i < 0 {
return "", "", errors.New("no dash")
}
return s[:i], s[i+1:], nil
}
GO
cat >sku_test.go <<'GO'
package sku
import "testing"
func FuzzParse(f *testing.F) {
f.Add("WH-1234")
f.Add("north-7")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
warehouse, number, err := Parse(s)
if err != nil {
return
}
if warehouse+"-"+number != s {
t.Errorf("Parse(%q) = %q, %q: does not round-trip", s, warehouse, number)
}
})
}
GO
say() { printf '$ %s\n' "$*"; }
tidy() {
sed -E 's/ \([0-9.]+s\)$//; s/^(ok|FAIL)([[:space:]]+example)[[:space:]]+[0-9.]+s$/\1\2/' test.txt
}
say 'go mod init example'
go mod init example 2>/dev/null || exit 1
say 'go test -v -run=FuzzParse'
go test -v -run=FuzzParse >test.txt 2>&1
status=$?
tidy
echo "exit status $status"
say 'mkdir -p testdata/fuzz/FuzzParse'
mkdir -p testdata/fuzz/FuzzParse
say "printf 'go test fuzz v1\\nstring(\"XX-9\")\\n' >testdata/fuzz/FuzzParse/legacy_prefix"
printf 'go test fuzz v1\nstring("XX-9")\n' >testdata/fuzz/FuzzParse/legacy_prefix
say 'go test -v -run=FuzzParse'
go test -v -run=FuzzParse >test.txt 2>&1
status=$?
tidy | grep -E '^(=== |--- | +--- |panic:|FAIL|ok|PASS|exit status)'
echo "exit status $status"
say 'go test -run=FuzzParse/legacy_prefix'
go test -run=FuzzParse/legacy_prefix >test.txt 2>&1
status=$?
tidy | grep -E '^(=== |--- | +--- |panic:|FAIL|ok|PASS|exit status)'
echo "exit status $status"
What to do¶
- Write the target as a property, not a case: what
Parseaccepts round-trips, an encoder's output decodes, a sort's output is ordered. A target that compares with expected values can only test the seeds. - Return early on inputs the property is not about — an error from the function under test is usually one — rather than reporting them. The engine rewards new coverage, and an input that hits the error path is still a useful seed.
- Commit
testdata/fuzz/. A found input is a regression test from then on, run by every plaingo test; and write one by hand for a bug report's input before fixing the bug. - Fuzz somewhere with a time budget — a nightly job,
-fuzztime=10m— and keep plaingo testin the commit gate. The seed run is fast and deterministic; the search is neither.
Practice¶
Write FuzzParseQuantity for the ParseQuantity of the earlier lessons, with the property that a quantity printed as "<n> <unit>" parses back to n and unit, and two corpus files by hand — negative_pallets with int(-3) and string("pallets"), and unit_with_a_space with int(2) and string("big crates"). Run the seed corpus and watch the second entry fail; then make the target skip units that contain white space, since the property cannot hold for them, and run it again.
Solution
fuzzing_finds_the_input_you_did_not_write_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Kata: a fuzz target for ParseQuantity whose property is that a quantity
// printed as "<n> <unit>" parses back to n and unit, with two corpus files
// written by hand in the fuzzer's own format -- one of them a unit with a
// space in it, which the property cannot hold for. The first run fails on
// that entry; the second, with the target skipping units that contain
// white space, passes. The program writes the module to a temporary
// directory and prints both transcripts with the durations stripped.
//
// go build fuzzing_finds_the_input_you_did_not_write_kata_go.go && ./fuzzing_finds_the_input_you_did_not_write_kata_go
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
const source = `package stock
import (
"errors"
"strconv"
"strings"
)
func ParseQuantity(s string) (int, string, error) {
fields := strings.Fields(s)
if len(fields) != 2 {
return 0, "", errors.New("want a number and a unit")
}
n, err := strconv.Atoi(fields[0])
if err != nil {
return 0, "", err
}
return n, fields[1], nil
}
`
const test = `package stock
import (
"fmt"
"strings"
"testing"
)
func FuzzParseQuantity(f *testing.F) {
f.Add(12, "crates")
f.Fuzz(func(t *testing.T, n int, unit string) {
GUARD
got, gotUnit, err := ParseQuantity(fmt.Sprintf("%d %s", n, unit))
if err != nil || got != n || gotUnit != unit {
t.Errorf("ParseQuantity(%q) = %d, %q, %v; want %d, %q", fmt.Sprintf("%d %s", n, unit), got, gotUnit, err, n, unit)
}
})
}
`
var durations = regexp.MustCompile(`(?m)( \([0-9.]+s\)$)|(^(ok|FAIL)(\s+example)\s+[0-9.]+s$)`)
func main() {
dir, err := os.MkdirTemp("", "kata")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
corpus := filepath.Join(dir, "testdata", "fuzz", "FuzzParseQuantity")
must(os.MkdirAll(corpus, 0o755))
must(os.WriteFile(filepath.Join(dir, "quantity.go"), []byte(source), 0o644))
must(os.WriteFile(filepath.Join(corpus, "negative_pallets"), []byte("go test fuzz v1\nint(-3)\nstring(\"pallets\")\n"), 0o644))
must(os.WriteFile(filepath.Join(corpus, "unit_with_a_space"), []byte("go test fuzz v1\nint(2)\nstring(\"big crates\")\n"), 0o644))
init := exec.Command("go", "mod", "init", "example")
init.Dir = dir
must(init.Run())
for _, guard := range []string{"", `if strings.ContainsAny(unit, " \t\n") || unit == "" {
t.Skip("the property is only for units without white space")
}`} {
body := strings.Replace(test, "GUARD", guard, 1)
if guard == "" {
body = strings.Replace(body, "\t\"strings\"\n", "", 1)
}
must(os.WriteFile(filepath.Join(dir, "quantity_test.go"), []byte(body), 0o644))
fmt.Println("$ go test -v -run=FuzzParseQuantity")
cmd := exec.Command("go", "test", "-v", "-run=FuzzParseQuantity")
cmd.Dir = dir
out, _ := cmd.CombinedOutput()
for _, line := range strings.Split(durations.ReplaceAllString(string(out), "$3$4"), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(line, "===") || strings.HasPrefix(trimmed, "---") || strings.HasPrefix(trimmed, "quantity_test.go:") || strings.HasPrefix(line, "ok") || strings.HasPrefix(line, "FAIL") || strings.HasPrefix(line, "PASS") {
fmt.Println(line)
}
}
fmt.Println("exit status", cmd.ProcessState.ExitCode())
}
}
func must(err error) {
if err != nil {
panic(err)
}
}
Verified output of fuzzing_finds_the_input_you_did_not_write_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.
$ go test -v -run=FuzzParseQuantity
=== RUN FuzzParseQuantity
=== RUN FuzzParseQuantity/seed#0
=== RUN FuzzParseQuantity/negative_pallets
=== RUN FuzzParseQuantity/unit_with_a_space
quantity_test.go:14: ParseQuantity("2 big crates") = 0, "", want a number and a unit; want 2, "big crates"
--- FAIL: FuzzParseQuantity
--- PASS: FuzzParseQuantity/seed#0
--- PASS: FuzzParseQuantity/negative_pallets
--- FAIL: FuzzParseQuantity/unit_with_a_space
FAIL
FAIL example
exit status 1
$ go test -v -run=FuzzParseQuantity
=== RUN FuzzParseQuantity
=== RUN FuzzParseQuantity/seed#0
=== RUN FuzzParseQuantity/negative_pallets
=== RUN FuzzParseQuantity/unit_with_a_space
quantity_test.go:13: the property is only for units without white space
--- PASS: FuzzParseQuantity
--- PASS: FuzzParseQuantity/seed#0
--- PASS: FuzzParseQuantity/negative_pallets
--- SKIP: FuzzParseQuantity/unit_with_a_space
PASS
ok example
exit status 0
In other languages¶
- The Rust library's Other kinds of test ↗ names
cargo-fuzz, a separate tool over libFuzzer that needs a nightly toolchain; in Go the engine ships ingo test, and the seed corpus is an ordinary test with nothing to install. - Python has no fuzzer in the standard library; Hypothesis ↗ is the property-based cousin, with
@givenstrategies in place off.Addseeds and a database of failing examples in place oftestdata/fuzz/.
Sources¶
testing, "Fuzzing" ↗ — the seed corpus, the accepted argument types, and the behaviour without-fuzz.- Go Fuzzing ↗ — the corpus file format, custom settings (
-fuzztime,-parallel), and the requirements and limitations. - Tutorial: Getting started with fuzzing ↗ — a
Reversefunction, a target, and a found input. - Fuzzing is Beta Ready ↗ — Katie Hockman and Jay Conrod, the Go blog, 2021; Go 1.18 release notes, "Fuzzing" ↗ — where it shipped.
- Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 11, "Testing" — 11.2.1, "Randomized Testing", the hand-rolled ancestor of a fuzz target.
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 15, "Writing Tests" — "Fuzzing".
- Miki Tebeka, Effective Go Recipes (Pragmatic Bookshelf, 2024), chapter 13, "Testing Your Code" — recipe 67, "Fuzzing Bugs Away".
- Go in Practice, 2nd ed. (Manning, 2025), chapter 6 — 6.3, "Unit testing in Go": "Fuzzing test input".