Go's native fuzzing automatically generates thousands of unexpected inputs to break your code, with no external dependency: since Go 1.18, go test -fuzz is part of the standard toolchain. This practical, optional lesson has you write a fuzz test on a home-made encoder, let the engine find a real crash in under a second, replay it, fix the bug, then fuzz again to confirm.
It is aimed at a Go developer who writes parsers, decoders or any function that handles untrusted inputs. You will be able to write a FuzzXxx, read the engine's output, explain the corpus (the set of test inputs) and the testdata/fuzz/ folder, and know the blind spots of native fuzzing.
All the code and all the outputs come from a replay under Go 1.27.2 (Linux, amd64). The general concept is explained in the lesson Fuzzing: finding the inputs that break your code, which this lesson assumes you know.
What you will learn
- Write a fuzz test with
testing.Fand the round-trip invariant. - Run
go test -fuzzand interpret its output (baseline, crash, minimisation). - Understand the corpus: seeds,
testdata/fuzz/, generated cache. - Replay a minimised crash as a regression test.
- Fix a bug uncovered by the fuzzer, then fuzz again to confirm.
- Know the limits of native fuzzing and how to complement them.
Prerequisites
Fuzzing is built into the language since Go 1.18, so there is nothing to install if a Go toolchain is present. This lesson was replayed with Go 1.27.2; any version greater than or equal to 1.18 will do. Check yours: the official documentation recommends a platform that supports coverage instrumentation, currently AMD64 and ARM64 (the Go 1.27.2 sources add loong64). The limit concerns coverage guidance, not fuzzing itself: -fuzz is accepted on Linux, macOS, Windows, FreeBSD and OpenBSD whatever the architecture, but on a non-instrumented architecture the corpus grows less meaningfully.
go versionThe output must show a version greater than or equal to 1.18, for example:
go version go1.27.2 linux/amd64Without Go installed, a container is enough: it provides the same toolchain and the current folder is mounted in it. With --rm, the generated corpus cache disappears when you leave the container, which does not matter for this lesson; to keep it, also mount a volume on the container's GOCACHE.
docker run --rm -it -v "$PWD":/src -w /src golang:1.27.2@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 bashFor the basics of unit tests in Go, see Testing Go code (table-driven) (in French).
A minimal comfort with go test and Go unit tests is enough. If the very concept of fuzzing (mutation, corpus, minimisation) is still unclear, first read the fuzzing lesson before coming back here.
Then create a working directory and a Go module. The name labo/rle, purely local, is the one that appears in this lesson's outputs:
mkdir rle && cd rlego mod init labo/rleThe files rle.go and rle_test.go shown below go there side by side. The output of go mod init suggests go mod tidy: it is unnecessary here, the code has no external dependency.
The code to test
To make the exercise concrete, we start from a deliberately naive run-length encoder (RLE). The idea of RLE is simple: compress repetitions. The string "aaabb" becomes "a3b2" (three a, two b). The rle.go file contains a real defect that the fuzzer will uncover.
// Package rle implements a naive run-length encoding, with a latent bug.// Format: each character is followed by its number of repetitions in decimal.package rle
import ( "strconv" "strings")
// Encode compresses a string with run-length encoding.func Encode(s string) string { if s == "" { return "" } var b strings.Builder runes := []rune(s) prev := runes[0] count := 1 for _, c := range runes[1:] { if c == prev { count++ continue } b.WriteRune(prev) b.WriteString(strconv.Itoa(count)) prev = c count = 1 } b.WriteRune(prev) b.WriteString(strconv.Itoa(count)) return b.String()}
// Decode decompresses an RLE string. It reads one character, then ALL the// consecutive digits as the counter.func Decode(s string) string { var b strings.Builder runes := []rune(s) i := 0 for i < len(runes) { char := runes[i] i++ digits := strings.Builder{} for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { digits.WriteRune(runes[i]) i++ } count := 1 if digits.Len() > 0 { count, _ = strconv.Atoi(digits.String()) } for j := 0; j < count; j++ { b.WriteRune(char) } } return b.String()}To the naked eye, this code looks correct: Encode("aaabb") does give "a3b2" and Decode("a3b2") rebuilds "aaabb". This is exactly the kind of false sense of security that fuzzing dismantles. Two defects hide here: the []rune conversion on one hand, and the ambiguity of the format with digits on the other. You will discover them in the order in which the engine reports them.
Writing a fuzz test
A Go fuzz test is nothing esoteric: it is a FuzzXxx function placed in a _test.go file, which receives a *testing.F. The right strategy here is the property test (property-based): rather than guessing specific cases, you state an invariant (a property that must hold) for every input. For an encoder, the natural invariant is the round-trip: Decode(Encode(s)) must always give back s.
Here is rle_test.go. Note the two building blocks: f.Add provides seeds (valid starting cases that prime the engine) and f.Fuzz receives the function driven by the generated inputs.
package rle
import "testing"
// FuzzRoundTrip checks the invariant Decode(Encode(s)) == s for any string s.func FuzzRoundTrip(f *testing.F) { // Seeds: valid cases that prime the engine. f.Add("aaabb") f.Add("abc") f.Add("")
f.Fuzz(func(t *testing.T, s string) { encoded := Encode(s) decoded := Decode(encoded) if decoded != s { t.Errorf("round-trip broken: %q -> %q -> %q", s, encoded, decoded) } })}Three structuring constraints frame this test. First, only one f.Fuzz per fuzz function. Second, the arguments of f.Add must have exactly the same types, in the same order, as those of the function passed to f.Fuzz: here a single string. A mismatch is refused as soon as go test runs: f.Add("abc") in front of a function that expects a []byte gives mismatched type in call to (*testing.F).Add: string, fuzz target expects []byte. Finally, only scalar types are accepted (string, []byte, signed and unsigned integers, rune, byte, float32/64, bool). No struct, no composite slice, no map and no pointer: the engine only knows how to mutate raw bytes.
Running go test -fuzz
Fuzzing is triggered by the -fuzz flag, which takes a regular expression that must match only one function. Without this flag, go test runs the seeds as plain unit tests and stops. You bound the duration with -fuzztime, which is essential for a reproducible run:
go test -fuzz=FuzzRoundTrip -fuzztime=20sThe output fits in a few lines. The engine first measures a reference coverage (baseline) on the three seeds, then starts its workers, processes that fuzz in parallel (their number follows your processors, 16 on the replay machine). The crash comes immediately:
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completedfuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 16 workersfuzz: minimizing 35-byte failing input filefuzz: elapsed: 0s, minimizing--- FAIL: FuzzRoundTrip (0.02s) --- FAIL: FuzzRoundTrip (0.00s) rle_test.go:16: round-trip broken: "\xfe" -> "�1" -> "�"
Failing input written to testdata/fuzz/FuzzRoundTrip/08ec2ca753054e71 To re-run: go test -run=FuzzRoundTrip/08ec2ca753054e71FAILexit status 1FAIL labo/rle 0.017sThree pieces of information matter here. The engine found a failing input in under five hundredths of a second. It minimised it: starting from a 35-byte file, it reduced it to a single significant byte, "\xfe". And it wrote the input into testdata/fuzz/FuzzRoundTrip/ with the exact command to replay it. The file name is the beginning of the content's hash: it is therefore identical for the same byte. The byte retained and the starting size vary from one run to another (eight other runs under Go 1.27.2 landed on "\x8f", "\xa7", "\xff" and other invalid bytes). In one of them, the engine reported the input "01" instead, which illustrates the second defect presented below: the order depends on chance, but the two classes of bug are those of this lesson. Your output will therefore probably show another byte and another file name; take your own in the replay commands. If fuzzing finds nothing in 20 seconds on your machine, run it again: the search is random.
The corpus and regressions
Go fuzzing handles three corpus locations that must not be confused. The seed corpus (seeds) combines the f.Add calls in the code and the versioned content of testdata/fuzz/<FuzzName>/. The generated corpus, for its part, lives in $GOCACHE/fuzz: it is the reservoir of interesting inputs that the engine keeps because they increase coverage. It persists between two -fuzz runs and the next run reuses it; go clean -fuzzcache empties it, and an ordinary go test never replays it (only the seed corpus and testdata/fuzz/ are). This cache is not meant to be versioned; its location is shown by go env GOCACHE.
The key point for the quality of your code: as soon as a crash is found, Go writes the minimised input into testdata/fuzz/, under Git. It joins the seed corpus and becomes a regression test replayed on every ordinary go test, even without -fuzz: run go test alone as long as the bug is not fixed, and it fails on this input (--- FAIL: FuzzRoundTrip/08ec2ca753054e71). Check it with ls testdata/fuzz/FuzzRoundTrip, which lists a file named by its hash, here 08ec2ca753054e71. It is readable text:
go test fuzz v1string("\xfe")The first line is a version header, the next one carries the typed value. This format also explains how to inject a known case by hand: you just drop such a file into testdata/fuzz/<FuzzName>/. The file2fuzz tool, which is not in the standard distribution (install it with go install golang.org/x/tools/cmd/file2fuzz@latest), converts binary files, such as those used by go-fuzz, to this format for a []byte argument.
Replaying a crash
Once the reproducer is written, you do not need to fuzz again to work on the bug. The -run flag targets the precise case through its hash, like any subtest. The hash changes with the faulty byte, so from one run to another: take the one that your output shows after To re-run:. The following example uses the run above:
go test -run="FuzzRoundTrip/08ec2ca753054e71"The failure replays identically, instantly and without workers:
--- FAIL: FuzzRoundTrip (0.00s) --- FAIL: FuzzRoundTrip/08ec2ca753054e71 (0.00s) rle_test.go:16: round-trip broken: "\xfe" -> "�1" -> "�"FAILexit status 1FAIL labo/rle 0.002sThis quick replay, fix, re-check cycle is the real working loop. You keep long fuzzing to search for new cases, and you use -run to debug an already known case without waiting.
Understanding and fixing
The message "\xfe" -> "�1" -> "�" hides the first bug, and it is specific to Go. A Go string is a sequence of bytes, supposed to be UTF-8 (the encoding of Unicode); a rune is a Unicode character. The lone byte 0xfe is not valid UTF-8. Now, Encode does []rune(s): this conversion replaces any invalid byte with the replacement character U+FFFD (displayed �). The information of the original byte is lost as soon as the conversion happens, so Decode can never rebuild "\xfe". The round-trip breaks before the RLE logic even comes into play.
The fuzzer reported this case first because it is the shortest: a single byte. But the second bug, the one of the format, is present too. It appears as soon as an input contains a digit. Check it directly:
Encode("a1") // -> "a111"Decode("a111") // -> "a" repeated 111 timesA throwaway test that prints the decoded length confirms the extent of the damage:
"a1" -> "a111" -> "aaaaaaaaaaa..." (len 111)"x9" -> "x191" -> "xxxxxxxxxxx..." (len 191)The cause is structural: Encode("a1") produces "a1" + "1" (the a followed by its counter 1, then the 1 followed by its counter 1), that is "a111". On decoding, Decode reads the a then swallows all the consecutive digits as the counter: it reads 111 and repeats a one hundred and eleven times. The format mixes data and counter without a separator: it is ambiguous, therefore unfixable as it stands.
The fix must address both causes at once. We give up string/[]rune to work on []byte (no byte is reinterpreted), and we make the format unambiguous by emitting (counter, byte) pairs where the counter is a raw byte never confused with the data. Here is rle_fixed.go, to place next to the other files:
package rle
// EncodeFixed works on []byte (no byte is reinterpreted) and emits// (counter, byte) pairs: the counter is a raw byte, never mixed with the// data. Runs longer than 255 bytes are split.func EncodeFixed(data []byte) []byte { var out []byte i := 0 for i < len(data) { b := data[i] count := 1 for i+count < len(data) && data[i+count] == b && count < 255 { count++ } out = append(out, byte(count), b) i += count } return out}
// DecodeFixed reverses EncodeFixed: it reads (counter, byte) pairs.func DecodeFixed(data []byte) []byte { var out []byte for i := 0; i+1 < len(data); i += 2 { count := int(data[i]) b := data[i+1] for j := 0; j < count; j++ { out = append(out, b) } } return out}You then write a new fuzz test on this version, with seeds that explicitly include the cases that broke the old implementation ("a1" for the digit, "\xfe" for the invalid byte, the crash shown above) in rle_fixed_test.go:
package rle
import ( "bytes" "testing")
func FuzzRoundTripFixed(f *testing.F) { f.Add([]byte("aaabb")) f.Add([]byte("abc")) f.Add([]byte("")) f.Add([]byte("a1")) f.Add([]byte("\xfe")) f.Add([]byte("01"))
f.Fuzz(func(t *testing.T, data []byte) { encoded := EncodeFixed(data) decoded := DecodeFixed(encoded) if !bytes.Equal(decoded, data) { t.Errorf("round-trip broken: %q -> %q -> %q", data, encoded, decoded) } })}In the general case, you fix the code in place, keep the testdata/fuzz/ file and go test goes green again: the recorded crash becomes the regression test of the fixed version. Here, the fix adds new functions (EncodeFixed, DecodeFixed) instead of modifying Encode and Decode, and this teaching choice changes the procedure: before fuzzing again, remove the old target. go test -fuzz starts by running all the tests of the package, seeds and regression files of testdata/fuzz/ included: the crash recorded for FuzzRoundTrip would be replayed on the old implementation and would make the run fail before any fuzzing (verified: --- FAIL: FuzzRoundTrip/08ec2ca753054e71, then FAIL, without a single fuzzing execution). So delete rle_test.go and the testdata/fuzz/FuzzRoundTrip/ folder. The regression is not lost: the new target takes up the two families of cases as seeds, "a1" and "01" for the digits, "\xfe" for the invalid UTF-8 byte.
rm rle_test.gorm -r testdata/fuzz/FuzzRoundTripYou then fuzz the fixed version again for 20 seconds:
go test -fuzz=FuzzRoundTripFixed -fuzztime=20sThis time the engine finds no failure and ends on a clean PASS. The figures below are an example measured on the replay; the throughputs and the numbers of executions vary with the machine and from one run to another; what matters is the absence of FAIL:
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 16 workersfuzz: elapsed: 6s, execs: 2660751 (433488/sec), new interesting: 13 (total: 19)fuzz: elapsed: 12s, execs: 5303678 (445061/sec), new interesting: 13 (total: 19)fuzz: elapsed: 20s, execs: 8675291 (418147/sec), new interesting: 13 (total: 19)PASSok labo/rle 20.108sIn this example, more than eight million executions and no crash: the new interesting line stabilises, a sign that the engine finds no more new interesting paths. This is not a proof of the absence of bugs, but a greatly strengthened confidence that the invariant now holds. An ordinary go test passes too. The testdata/fuzz/ folder still exists but contains nothing any more (rm -r only removed the sub-folder of the old target): the fixed version found no new case to record. To clean up, delete the rle folder created in the prerequisites.
Fuzzing in CI
Fuzzing in continuous integration obeys a simple rule: bound the time. A campaign of several hours belongs in a scheduled task or on dedicated infrastructure (OSS-Fuzz for open source projects), not on a pull request. In CI, you run a short run that mostly checks that the regressions of the versioned corpus pass and that no obvious crash appears:
go test -fuzz=FuzzRoundTripFixed -fuzztime=30s .The . matters: -fuzz only works on a single package. With ./... in a module that contains several, Go answers cannot use -fuzz flag with multiple packages. To replay the regressions of all the packages without fuzzing, an ordinary go test ./... is enough.
Two habits make this step useful. First, version testdata/fuzz/ so that every normal go test replays the known regressions, even in jobs without -fuzz. Second, keep the bounded fuzz short (30 seconds are enough to catch quick regressions), and delegate long fuzzing to dedicated infrastructure. A scheduled campaign must keep the $GOCACHE/fuzz cache from one job to the next (the CI cache), otherwise each campaign starts from zero. The lesson on fuzzing details this split between short and long.
The limits of native fuzzing
Go's built-in fuzzing is remarkably accessible, but it does not see everything. It is a coverage-guided engine on scalar types, and this draws precise blind spots that you must know so as not to wrongly believe yourself covered. The table below summarises what it does not detect by itself.
| Blind spot | Why | How to complement |
|---|---|---|
| Data races | The engine judges the output, not concurrency | Run with -race |
| Integer overflows | A silent overflow does not panic | Assertions, code review |
| Goroutine leaks | No notion of a goroutine leak | Dedicated detectors in tests |
| Complex comparisons | The engine struggles to pass an == on a long literal | Targeted seeds that already contain the literal (the native engine has no dictionary option) |
| Composite types | No struct, no map, not structure-aware | Encode/decode manually to []byte |
The most profitable remedy is to combine -fuzz and -race: the latter instruments the concurrent accesses that the former ignores. For goroutine leaks and rich business invariants, you must add assertions and dedicated detectors in the test itself. The message to remember: native fuzzing is an excellent starting point, not complete coverage. An expected error (an error that your code returns on purpose) must moreover never make the test fail: you only fail on a panic, a violated invariant or corruption.
Key points
- Go fuzzing is native since 1.18: no dependency, just
go test -fuzz. - A fuzz test is a
FuzzXxx(f *testing.F)function with a singlef.Fuzz, scalar types only, andf.Addarguments of the same types as those of the fuzzed function. - The property test (round-trip
Decode(Encode(s)) == s) is the most productive pattern for an encoder or a parser. - The engine minimises the failing input (here 35 bytes reduced to 1) and writes it into
testdata/fuzz/as a versioned regression test. - Replay a known crash with
-run=FuzzXxx/<hash>, without launching a campaign again. - The fuzzer reported the UTF-8 bug here (
[]runereplaces an invalid byte with U+FFFD) before the ambiguous-format bug on digits, because its reproducer is the shortest. The fix works on[]bytewith an unambiguous format and passes, in the measured example, more than 8 million executions without failure. - The native engine detects neither data races, nor overflows, nor goroutine leaks: complement it with
-raceand assertions in the test. - In CI,
-fuzztargets only one package: give it.and not./....
Knowledge check
Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Why static secrets are a problem
A new full quiz with random questions
Work again only on the questions you missed