Skip to content
gohacks.dev
Testing

Write Good Fuzz Targets in Go for Parsers and Decoders

Write fuzz targets for Go parsers and decoders: the no-panic and round-trip properties, good seeds, and a key=value parser that fuzzing breaks in seconds.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Parsers and decoders are the best use of a fuzzer. They take bytes from somewhere you do not control, they branch heavily on what they see, and their failures are crashes, which a fuzzer detects without any help from you. A function that turns a string into a struct is also easy to test with properties, because you can turn the struct back into a string.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article shows how to write a fuzz target for such a function, using a small key and value parser on Go 1.25.6. The basics of running a fuzzer, including flags and CI, are in the fuzz testing guide. Here the focus is on the target: what to assert, what to seed, and how to read what the fuzzer finds.

A parser that passes its tests

kv.goGo
package main

import (
	"fmt"
	"sort"
	"strings"
)

// Parse reads "k=v,k2=v2" into a map.
func Parse(s string) map[string]string {
	out := map[string]string{}
	for _, part := range strings.Split(s, ",") {
		if part[0] == ' ' { // trims one leading space; panics on an empty part
			part = part[1:]
		}
		k, v, _ := strings.Cut(part, "=")
		out[k] = v
	}
	return out
}

// Format writes the map back in sorted key order.
func Format(m map[string]string) string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, len(keys))
	for i, k := range keys {
		parts[i] = fmt.Sprintf("%s=%s", k, m[k])
	}
	return strings.Join(parts, ",")
}

func main() {}

Parse reads a=1,b=2 into a map. It trims one leading space per part, so a=1, b=2 works, and it splits each part at the first =. Format writes a map back in sorted order. A table test with a=1,b=2, a=1, b=2, an empty value and a missing = would pass, and the code looks reasonable. It has a bug that no such table contains.

The target

kv_test.goGo
package main

import (
	"reflect"
	"testing"
)

func FuzzParse(f *testing.F) {
	f.Add("a=1,b=2")
	f.Add("x=")
	f.Fuzz(func(t *testing.T, s string) {
		m := Parse(s) // property 1: never panic
		again := Parse(Format(m))
		if !reflect.DeepEqual(m, again) { // property 2: format then parse is the identity
			t.Errorf("round trip changed %q: %v -> %q -> %v", s, m, Format(m), again)
		}
	})
}

FuzzParse states two properties in one function.

The function must not panic. Calling Parse(s) with whatever the engine supplies and checking nothing is already a test, and for a parser it is the most valuable one. A Go panic in a handler is a crashed request or a dead service. This property needs no knowledge of the correct output, so it works for every input.

Format then parse is the identity. again := Parse(Format(m)) must equal m. You cannot compare Parse(s) with an expected value, but you can check that the parsed value survives a trip through the printer. It holds only when Format and Parse agree on what the data means, and a mismatch tells you about a bug in one of them, with the input that shows it.

Two seeds are enough: "a=1,b=2" is a valid example, and "x=" is an edge case with an empty value. The engine mutates from them, so seeds that resemble real input help it reach deeper code quickly. For a format with structure, such as a header block or a config file, seed with two or three real samples of different shapes. Seeds in the testdata/fuzz directory work the same way and suit larger files.

What the fuzzer found

Shell
rm -rf testdata
F='s/[0-9a-f]{16}/<hash>/; s/ \([0-9.]+s\)//; s/[0-9.]+s$/<time>/'
go test -run='^$' -fuzz=FuzzParse -fuzztime=10s . 2>&1 | grep -v '^fuzz: elapsed' | sed -E "$F" | head -4
echo "---"
cat testdata/fuzz/FuzzParse/* | head -3
Text
fuzz: minimizing 29-byte failing input file
--- FAIL: FuzzParse
    --- FAIL: FuzzParse
        testing.go:1825: panic: runtime error: index out of range [0] with length 0
---
go test fuzz v1
string(",")

The run is short because the bug is shallow. The fuzzer reported index out of range [0] with length 0 and saved its input. The saved file has the header go test fuzz v1 and the value string(","): a lone comma, minimised from whatever longer input first failed.

Look at why. Splitting "," on commas gives two empty strings. Parse then checks part[0] == ' ' on an empty part, and that is an index out of range. Any input with a leading comma, a trailing comma or two commas in a row has an empty segment, and a=1, is as likely in a real config as in a fuzzer. None of the four example inputs in the table test had one.

The fix is a single condition, strings.HasPrefix(part, " ") in place of the index, or skipping empty parts. Apply it, and re-run the target. This is the real working loop: fix one finding, run again, read the next. The no-panic property will stop failing first. The round-trip property is stricter and fails next, on inputs such as a=b=c, where the value contains =, or on a key that contains , or a leading space. Those findings are not crashes; they are places where Parse and Format disagree about the grammar. Each needs a decision: reject the input, escape it, or document that it is not supported.

Keep the loop fast

A fuzz function that is slow finds little. The engine's useful work is executions per second, and every millisecond in the body multiplies across the run.

  • No I/O, no network, no clock. Parse from memory.
  • No global state that grows. A cache or a counter that persists across calls changes behaviour between executions and makes failures unreproducible. If a package-level cache is unavoidable, reset it at the top of the function.
  • Avoid expensive setup. Build large lookup tables once, outside f.Fuzz. Code before the f.Fuzz call runs once.
  • Bound the work. A decoder that allocates a slice of the size a header declares can be made to ask for 4 GB by a single byte. The fuzzer will find that, and the engine reports it as an out of memory failure; the fix is a limit, and the limit belongs in the code as well as in the target.

Skip inputs that are outside the function's contract with t.Skip() instead of returning silently. A skipped execution is counted as skipped, which keeps the signal honest about how much of the input space was actually exercised.

Decoders and formats with structure

Text formats like the one above are easy. Binary decoders and formats with checksums or length fields need a little more thought, because most mutated bytes produce an input that is rejected at the first check, so the fuzzer never reaches the code behind it.

  • Fuzz at the right layer. If a decoder verifies a CRC before parsing, fuzz the parsing function that runs after the check, or compute a valid checksum inside the target.
  • Use structured seeds. Include a real, valid message. The engine mutates it into near-valid inputs, which reach deep.
  • Build the input from typed fields. The target can take (version byte, length uint16, payload []byte) and assemble a message, so each mutation is a meaningful change.
  • Use a differential check where a reference exists. If you wrote a faster JSON decoder, compare its output with encoding/json on the same input. Disagreement is a bug in one of them.

Where to put the targets

Place the fuzz target next to the function in *_test.go, in the same package. The seeds run in every go test, so the target costs nothing to keep. Commit the testdata/fuzz/<FuzzName> directory, which holds failures the engine found and which grows slowly. Run the real fuzzing in a scheduled job, as the guide describes, and keep a short 30 second run in pull requests for packages whose parsers changed.

When the fuzzer reports a failure, check the saved input into the repository before changing the code, run the target to see it fail, and then fix. This proves the target would have caught the bug, which is what the file is for. The Go reference on corpus entries describes the format if you want to write one by hand, and the testing.F documentation lists the supported argument types.

The same properties apply to formats that are not text. A decoder for the JSON-like data you receive from an API is a natural next target. Before you write it, read about the bugs that fuzzing finds in real Go code: most come from a short list of mistakes, and recognising them makes the properties easier to choose.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.