Blog
November 17, 2021 Marie H.

Fuzz Testing in Go: Finding Bugs You Didn't Know to Look For

Fuzz Testing in Go: Finding Bugs You Didn't Know to Look For

Photo by <a href="https://unsplash.com/@jakubzerdzicki?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Jakub Żerdzicki</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

Fuzz Testing in Go: Finding Bugs You Didn't Know to Look For

Unit tests verify behavior you thought to test. Fuzz testing finds behavior you never thought to test. That distinction matters more than most people realize until the first time a fuzzer surfaces a panic that's been silently lurking in their parsing code for two years.

Go is getting native fuzzing support. It's available in the tip build right now (as of December 1, 2021 in go tip), scheduled to ship stable in Go 1.18. Let me walk through how it works, show a real example, and explain how to integrate it without making your CI pipeline unbearable.

Updated March 2026: Go 1.18 shipped in March 2022 with native fuzzing stable. The testing.F API described here is exactly what landed. The Go team has continued improving the fuzzer engine and the corpus tooling in subsequent releases. Everything below reflects what's now production-ready.

What Fuzzing Is and Why You Want It

Fuzzing is automated input generation for finding crashes, panics, and unexpected behavior. The fuzzer starts from a seed corpus — examples of valid inputs you provide — and mutates them systematically, watching for anything that causes a panic, an unhandled error, or a violation of an invariant you specify.

This is different from property-based testing (though related). Property-based testing asks "does this hold for all inputs?" Fuzzing is more pragmatic: "can I make this code panic or return garbage?" The fuzzer doesn't need you to define what correct behavior looks like for every input — it just needs to know what broken looks like (a panic is broken; an unrecovered error is broken).

The kinds of bugs fuzzing finds well: integer overflows in length calculations, off-by-one errors in parsers, nil pointer dereferences on malformed input, unexpected behavior at encoding boundaries (what happens when your config parser sees a key with no value? what about a value that's 10MB?). These are exactly the bugs that slip through normal test suites because nobody thought to write TestParseConfig_KeyWithNoValue_10MBValue.

The Old Way: go-fuzz

Before native support, the standard tool was Dmitry Vyukov's go-fuzz. It works, and it's found a lot of real bugs in production Go code (the Go standard library has a corpus of go-fuzz findings).

The model requires you to write a Fuzz function with a specific signature in a build-tagged file:

//go:build gofuzz

package urlparser

func Fuzz(data []byte) int {
    _, err := ParseURL(string(data))
    if err != nil {
        return 0
    }
    return 1
}

Then you run go-fuzz-build and go-fuzz separately. It works but it's friction: separate binaries, separate corpus format, not integrated with go test. The native approach eliminates all of that.

The New API: testing.F

Native Go fuzzing introduces a new type testing.F alongside the familiar testing.T and testing.B. Fuzz targets live in _test.go files alongside your unit tests. No build tags, no separate toolchain.

The structure of a fuzz test:

func FuzzParseURL(f *testing.F) {
    // Seed corpus: valid examples that guide the fuzzer
    f.Add("https://example.com/path?q=1")
    f.Add("http://user:pass@host:8080/path")
    f.Add("")
    f.Add("not-a-url-at-all")

    f.Fuzz(func(t *testing.T, input string) {
        // This function must not panic for any input.
        // Use t.Fatal/t.Error for violations of invariants you can check.
        result, err := ParseURL(input)
        if err != nil {
            return // errors are acceptable; panics are not
        }
        // Example invariant: round-trip should be stable
        if result.String() != "" {
            reparsed, err := ParseURL(result.String())
            if err != nil {
                t.Errorf("round-trip parse failed: original=%q reparsed_input=%q err=%v",
                    input, result.String(), err)
            }
            _ = reparsed
        }
    })
}

Two methods to know: f.Add(...) seeds the corpus with initial examples. f.Fuzz(func(t *testing.T, ...)) is the function that runs against each generated input. The arguments to the fuzz function beyond *testing.T define the types the fuzzer mutates — you can use string, []byte, int, bool, and combinations of these.

Let me show a more realistic example with a config file parser:

func FuzzParseConfig(f *testing.F) {
    f.Add([]byte("key=value\n"))
    f.Add([]byte("# comment\nkey=value\nother=123\n"))
    f.Add([]byte(""))
    f.Add([]byte("[section]\nkey=value\n"))

    f.Fuzz(func(t *testing.T, data []byte) {
        cfg, err := ParseConfig(bytes.NewReader(data))
        if err != nil {
            return
        }
        // Invariant: serializing and re-parsing should produce identical config
        var buf bytes.Buffer
        if err := cfg.WriteTo(&buf); err != nil {
            t.Fatalf("WriteTo failed on successfully parsed config: %v", err)
        }
        cfg2, err := ParseConfig(&buf)
        if err != nil {
            t.Fatalf("re-parse of serialized config failed: %v", err)
        }
        if !reflect.DeepEqual(cfg, cfg2) {
            t.Errorf("round-trip not stable:\noriginal input: %q\nfirst parse: %+v\nsecond parse: %+v",
                data, cfg, cfg2)
        }
    })
}

The round-trip invariant is a powerful pattern for parsers: if you can parse it, you should be able to serialize and re-parse to get the same result. If you can't, you have a bug even if neither parse panics.

Running It

Without the -fuzz flag, go test just runs the fuzz function against the seed corpus. This is fast and useful — it's essentially a parametric unit test over your seeds:

go test ./... # runs seed corpus, no fuzzing

To actually fuzz, supply -fuzz with a pattern matching the function name:

go test -fuzz=FuzzParseURL -fuzztime=60s ./pkg/urlparser/

The -fuzztime flag limits how long the fuzzer runs. Without it, it runs until it finds a failure or you kill it. For interactive development, 60 seconds is enough to get a feel for whether the fuzzer is finding anything.

When the fuzzer finds a failure, it writes the failing input to testdata/fuzz/<FuzzFunctionName>/ as a numbered file. On the next go test run (without -fuzz), that file is included in the seed corpus and the failure will reproduce. This is the corpus.

Interpreting Corpus Output

The files in testdata/fuzz/ are plain text:

go test fuzz v1
string("http://\x00user@host")

This is exactly what caused the panic. You can now write a regular unit test for this specific case if you want to pin the fix, or let the corpus file serve that purpose (which is cleaner).

Commit your corpus to version control. The seed files in testdata/fuzz/ are small and they represent real knowledge about edge cases your parser has encountered. Losing them means losing that coverage.

CI Integration

Here's the pattern I use: in CI, run fuzz targets against the committed corpus only (no -fuzz flag). This is fast — it just exercises the seed corpus as unit tests. Any failing input that was previously discovered and committed will catch regressions.

# In your GitHub Actions workflow or equivalent:
- name: Run fuzz corpus (no active fuzzing)
  run: go test ./...

For extended fuzzing, run it separately — either as a scheduled job or as part of a dedicated fuzzing pipeline:

- name: Fuzz for 5 minutes
  run: |
    go test -fuzz=FuzzParseURL -fuzztime=5m ./pkg/urlparser/
    go test -fuzz=FuzzParseConfig -fuzztime=5m ./pkg/config/

If the scheduled fuzz run finds something new, it commits the failing input back to the corpus. This keeps the CI feedback loop fast while still accumulating coverage over time.

One caution: don't run -fuzz on every PR without a time limit. The fuzzer will happily run for hours. Bound it with -fuzztime and be deliberate about which targets you're actively fuzzing.

What to Fuzz First

Start with code that handles external input: parsers (config files, data formats, API request bodies), decoders (binary formats, encodings), and anything that takes a []byte or string from outside your trust boundary. These are where malformed input causes real problems.

If you work on cryptographic or key management code, fuzz your serialization and deserialization paths. I've found off-by-one errors in length-prefixed encoding that would have caused subtle key material corruption — not a panic, but wrong output on specific edge case lengths. The round-trip invariant catches these where a panic-only approach wouldn't.

Native fuzzing in Go is the first time fuzzing has been this low-friction in any language I regularly use. No excuses now.