Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Native Go fuzzing: testing with go test -fuzz

By updated

Improve this page

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.F and the round-trip invariant.
  • Run go test -fuzz and 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.

Terminal window
go version

The output must show a version greater than or equal to 1.18, for example:

go version go1.27.2 linux/amd64

Without 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.

Terminal window
docker run --rm -it -v "$PWD":/src -w /src golang:1.27.2@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 bash

For 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:

Terminal window
mkdir rle && cd rle
go mod init labo/rle

The 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:

Terminal window
go test -fuzz=FuzzRoundTrip -fuzztime=20s

The 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 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 16 workers
fuzz: minimizing 35-byte failing input file
fuzz: 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/08ec2ca753054e71
FAIL
exit status 1
FAIL labo/rle 0.017s

Three 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 v1
string("\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:

Terminal window
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" -> "�"
FAIL
exit status 1
FAIL labo/rle 0.002s

This 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 times

A 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.

Terminal window
rm rle_test.go
rm -r testdata/fuzz/FuzzRoundTrip

You then fuzz the fixed version again for 20 seconds:

Terminal window
go test -fuzz=FuzzRoundTripFixed -fuzztime=20s

This 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 workers
fuzz: 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)
PASS
ok labo/rle 20.108s

In 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:

Terminal window
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 spotWhyHow to complement
Data racesThe engine judges the output, not concurrencyRun with -race
Integer overflowsA silent overflow does not panicAssertions, code review
Goroutine leaksNo notion of a goroutine leakDedicated detectors in tests
Complex comparisonsThe engine struggles to pass an == on a long literalTargeted seeds that already contain the literal (the native engine has no dictionary option)
Composite typesNo struct, no map, not structure-awareEncode/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 single f.Fuzz, scalar types only, and f.Add arguments 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 ([]rune replaces an invalid byte with U+FFFD) before the ambiguous-format bug on digits, because its reproducer is the shortest. The fix works on []byte with 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 -race and assertions in the test.
  • In CI, -fuzz targets 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

6 questions
6 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn