Fuzzing generates random inputs to find bugs that hand-written tests miss. Go ships native fuzzing in the standard testing package since Go 1.18, so you write a FuzzXxx function next to your regular tests and the toolchain handles input generation, crash detection, and corpus management. This chapter covers the func FuzzXxx(f *testing.F) shape, seed corpus with f.Add, the fuzz target body, the -fuzz and -fuzztime flags, the generated corpus on disk, and the property patterns that make fuzzing pay for itself.
Fuzzing is most effective for code that parses, decodes, or transforms untrusted input. A coupon-code parser that turns "SAVE10" into a discount struct, a price formatter that handles negative and overflow values, a CSV importer that reads product rows from a file: each one has an input space too big for any hand-written test suite to cover. The fuzzer finds the input that crashes, panics, or returns a wrong answer that a thoughtful test author would have missed.
For pure, total functions with a small input space (a Square(x int) int, an enum-to-string mapper, a function that picks the larger of two strings), fuzzing is overkill. Regular table tests cover those better because the input space is small and obvious. Save fuzzing for code where the inputs are bytes, strings, or numbers from outside your control.
A fuzz test lives in a _test.go file and has the signature func FuzzXxx(f *testing.F). The function does two things: it seeds the corpus with example inputs using f.Add, and it calls f.Fuzz with the target function the runner will invoke with each generated input.
f.Add registers seed inputs, one call per seed. f.Fuzz takes a callback with a *testing.T and one argument per seeded value; the runner generates new inputs of the same type and invokes the callback. The callback uses t just like a regular test, so t.Errorf and t.Fatalf work normally.
Without the -fuzz flag, this test just runs the seed corpus as if each seed were a regular subtest. That's important: go test ./... runs fuzz tests in seed-only mode by default, so adding one to your repo doesn't slow down regular test runs.
The seed inputs ran, none failed, the test passed. To actually fuzz (generate new inputs), pass -fuzz:
The fuzzer is now actively generating new inputs derived from the seeds. It tracks code coverage to favor inputs that exercise new branches, which is how it makes progress through a large input space efficiently.
Cost: Fuzzing runs forever by default. Always pass -fuzztime (a duration like 30s or 5m) or stop it manually with Ctrl-C. CI pipelines should set a fixed budget so the job finishes.
The fuzz target accepts a fixed set of parameter types. The runner has to generate values of these types, and the design keeps the set small on purpose:
| Type | Notes |
|---|---|
string | Generated as random Unicode strings |
[]byte | Generated as random byte slices |
int, int8, int16, int32, int64 | Integer types, signed |
uint, uint8, uint16, uint32, uint64 | Integer types, unsigned |
float32, float64 | Including NaN and Inf in some runs |
bool | Just true and false |
byte | Same as uint8 |
rune | Same as int32 |
The target can take more than one parameter. Each f.Add call must provide values in the same order and types as the fuzz target's parameters.
Structs, maps, slices of non-byte types, and interfaces aren't supported. If your function takes a struct, write a small fuzz target that takes the underlying primitive fields and constructs the struct inside the target body. The runner doesn't know how to generate a Product, but it knows how to generate strings and floats that you can pass to Product{Name: name, Price: price}.
The seeds you pass to f.Add are the starting point. The fuzzer mutates these (flips bits, truncates strings, multiplies numbers, splices parts together) to generate new inputs. Any input that finds new code coverage is saved to disk and reused in future runs, building up a generated corpus over time.
There are two locations:
| Location | What lives there |
|---|---|
f.Add(...) calls in the test file | The explicit seed corpus, committed to source control |
testdata/fuzz/FuzzXxx/ | The generated corpus, written by the fuzzer when it finds interesting inputs |
The fuzzer reads both before each run. Adding seeds to f.Add is for inputs you specifically want covered (edge cases, regression triggers). The testdata/fuzz/FuzzXxx/ directory grows automatically; you can commit it to source control to share interesting inputs across machines, or leave it out via .gitignore if you don't want the noise.
Each file in testdata/fuzz/FuzzXxx/ is a serialized input. The file name is a hash. The contents look like:
The header line is the format marker. The lines below are one per parameter, in the same order as the fuzz target. To run a specific saved input as a regression test, use go test -run=FuzzParse/a0b1c2d3... and it executes just that one input.
The interesting part. When the fuzzer finds an input that makes the target panic, fail an assertion, or time out, it writes a file under testdata/fuzz/FuzzXxx/ and reports the failure. The next go test (even without -fuzz) runs that input as a regular test case, so the failure is now a regression test in your suite forever.
The output looks like:
The failing input is saved, the stack trace points at the panic, and the re-run line gives the exact command to reproduce. Fix the bug, run go test again, the saved input becomes a passing test, and you commit both the fix and the testdata file. That's the loop.
Cost: A failure artifact is small (a few bytes per file), but a long fuzz run that finds many coverage-expanding inputs can grow the testdata/fuzz/ directory to thousands of files. Periodically clean it up if size matters, or rely on .gitignore to keep the directory out of source control.
A fuzz target that just calls the function and looks for panics catches crashes but not wrong answers. The next level is asserting a property: something that should be true for every input. Two patterns cover most cases.
The symmetry property asserts that an operation and its inverse round-trip. If you have Marshal and Unmarshal, then Unmarshal(Marshal(x)) should equal x. If you have Encode and Decode, same idea.
The t.Skip() matters. Some inputs may not be valid for Encode (a code with invalid characters, an amount that overflows). Skipping means "this input isn't relevant to this property", and the fuzzer doesn't count it as a failure. Without Skip, the fuzzer would flag the input as a bug even though the only thing wrong is that the input shouldn't have been passed to Encode in the first place.
The invariant property asserts a fact that should hold regardless of input. A price formatter should never return a string longer than some bound, a length should never be negative, a total should never overflow into a smaller number than its parts:
Each t.Errorf checks an invariant: the output is never empty, never contains the literal NaN or Inf, never exceeds 64 characters. The fuzzer hunts for any input that violates these. If your code calls strconv.FormatFloat directly with no NaN check, the fuzzer will surface a NaN input within seconds.
Here's a complete fuzz target file for a coupon-code parser. The parser takes a string like "SAVE10" or "PCT-25" and returns a Discount struct or an error. The fuzz target looks for crashes and asserts two invariants: the parser never panics, and a returned discount is always within a sane range.
A few choices worth calling out. The recover in a defer converts panics into test failures, which gives a nicer error message than a raw stack trace and includes the offending input. The return after a non-nil error means "the parser rejected this string, that's fine, nothing more to check". The two Errorf calls are invariants: any well-formed result must have percent in [0, 100] and cents non-negative.
Run it for 30 seconds:
No failures, 22 new corpus-worthy inputs found. Those inputs are saved to testdata/fuzz/FuzzParseCoupon/ and will replay as regular tests on the next go test run. The next time someone refactors ParseCoupon, those inputs guard against regressions.
A CSV product importer is a classic fuzzing target because the input is bytes that have to be parsed, fields have to be the right count, and numbers have to fit. The fuzz target just hands the importer arbitrary byte slices:
[]byte is one of the supported types, so the runner generates arbitrary byte sequences directly. The invariants reflect what a "product" should always look like: non-negative price, non-negative stock, non-empty name. If ImportProducts ever returns a product that violates one of these without surfacing an error, the fuzzer catches it.
The diagram shows the feedback loop. The fuzzer pulls from both the explicit seed corpus (your f.Add calls) and the saved generated corpus (testdata/fuzz/), mutates an input, runs the target, and decides what to do with the result. Inputs that hit new code paths get saved for future runs, inputs that fail get saved as failure artifacts, and inputs that do neither are discarded. This coverage-guided process is why fuzzing finds bugs that random input wouldn't: the fuzzer steers toward unexplored corners of the code.
Fuzzing has a cost: setup time, CI minutes, corpus management, and the cognitive overhead of writing good property assertions. It pays off for code where the input space is large and adversarial. For everything else, regular table tests are simpler and faster.
| Code shape | Use |
|---|---|
| Parser, decoder, deserializer | Fuzzing |
| Anything taking untrusted user input | Fuzzing |
| Crypto primitive, hash function, encoder | Fuzzing |
| Pure math function with small input range | Table tests |
| Enum-to-string mapping | Table tests |
| Function with a few well-defined cases | Table tests |
| Code coordinating goroutines or I/O | Neither (use integration tests) |
The rule of thumb: if a thoughtful test author can enumerate the interesting inputs in a few minutes, fuzzing won't find anything they missed. If the input space is bytes, large strings, or numerical ranges that include weird edge cases (overflow, NaN, empty), fuzzing finds bugs that take humans weeks to discover.
Cost: Fuzz tests that share heavy global setup (database connections, file caches) are a poor fit. The fuzzer runs tens of thousands of iterations per second, so any per-iteration setup gets amplified. Pure functions of their inputs are the natural target.
10 quizzes