Skip to content

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:

Real runs — go1.25.5, x86-64 Mac, 1 run, 2026-09-23
$ 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 Parse accepts 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 plain go 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 plain go test in 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 in go 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 @given strategies in place of f.Add seeds and a database of failing examples in place of testdata/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 Reverse function, 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".