Fuzzing in Go

Enable Go fuzzing with the -asan flag and run tests using -test.fuzz to automatically detect memory errors and logic bugs.

The bug you didn't imagine

You wrote a JSON parser. It passes all your unit tests. You ship it. A user sends a payload with a nested object that has a key containing a null byte followed by a specific sequence of unicode escapes. Your program panics. You spent hours writing test cases for empty strings, long inputs, and malformed brackets. You missed the one combination that breaks production.

This happens because human-written tests only cover what you can imagine. You test the happy path, the obvious errors, and the edge cases that come to mind. The space of possible inputs is effectively infinite. Fuzzing automates the imagination. It generates millions of random, mutated inputs to find the crashes you never thought to test for.

What fuzzing actually does

Fuzzing is automated testing where the tool generates inputs to trigger failures. You give the fuzzer a valid example of input, called a seed. The fuzzer runs your code with that seed, then mutates the input slightly and runs it again. It repeats this cycle millions of times, keeping track of which inputs cause crashes or panics.

Think of it like a stress test for your code's input handling. You hand the fuzzer a working request, and it starts throwing garbage at your function until something breaks. It's like feeding a robot a sandwich, then a brick, then a stream of binary noise, just to see if it chokes. The difference is that Go's fuzzer gets smarter as it goes. It uses coverage information to prioritize mutations that hit new code paths. It isn't just random noise. It's a directed search for bugs.

Fuzzing finds the bugs you didn't know existed.

Minimal fuzz test

A fuzz test lives in a _test.go file, just like a unit test. The function name must start with Fuzz and take a *testing.F argument. You seed the corpus with known inputs, then define the target function.

package mypkg

import "testing"

// ParseID extracts an integer from a string.
// It returns the integer or -1 if parsing fails.
func ParseID(s string) int {
    // Simple parsing logic for demonstration.
    // Real code might use strconv.Atoi or regex.
    if len(s) == 0 {
        return -1
    }
    // Simulate a bug: panic on specific length.
    if len(s) == 5 && s[0] == 'x' {
        panic("unexpected input")
    }
    return 42
}

// FuzzParseID tests ParseID with random inputs.
func FuzzParseID(f *testing.F) {
    // Seed the corpus with known good inputs.
    // The fuzzer uses these as starting points for mutation.
    // Without seeds, the fuzzer starts from scratch and may take longer to find valid paths.
    f.Add("123")
    f.Add("abc")

    // f.Fuzz defines the target function.
    // The fuzzer generates inputs matching the signature.
    // Here it generates strings to pass to the inner function.
    f.Fuzz(func(t *testing.T, input string) {
        // Run the function under test.
        // Any panic or explicit t.Fail marks this input as a failure.
        // The fuzzer reports the minimal input that triggers the failure.
        ParseID(input)
    })
}

The f.Add calls are essential. They populate the initial corpus. The fuzzer mutates these seeds to explore the input space. If you skip seeding, the fuzzer has to discover valid inputs from pure randomness, which can be inefficient.

Run the fuzz test with go test -fuzz=FuzzParseID. The fuzzer runs until you stop it. It prints progress updates showing the number of executions per second and any failures found. If it finds a panic, it saves the failing input to a file and stops. You can then use that input to reproduce and fix the bug.

The corpus grows as the fuzzer finds interesting inputs.

How the fuzzer learns

Go's fuzzer is coverage-guided. It instruments your code to track which branches and paths are executed. When a mutation hits a new code path, the fuzzer treats that input as valuable and adds it to the corpus. It then focuses future mutations on inputs that explore uncovered areas.

This means the fuzzer isn't just throwing darts. It's mapping your code. If your function has a complex if chain, the fuzzer will try to find inputs that satisfy each condition. It prioritizes mutations that increase coverage. This makes it much more effective than pure random testing.

You can see the corpus in the fuzz/ directory inside your package. The directory contains subdirectories for each fuzz target. Each subdirectory holds the seed inputs and any new inputs the fuzzer discovered. You should commit this directory to version control. The corpus is a valuable artifact. It captures the edge cases the fuzzer found, and it speeds up future fuzzing runs by providing a rich starting point.

Commit your corpus. The fuzzer builds on what you save.

Realistic example: validating user input

Fuzzing shines when you handle untrusted data. Parsers, decoders, and validators are prime targets. Here is a realistic example of fuzzing a URL validator. The function checks if a URL is well-formed and uses HTTPS.

package validator

import (
    "fmt"
    "net/url"
    "testing"
)

// ValidateURL checks if a URL string is well-formed and safe.
// It returns an error if the URL is invalid or uses an insecure scheme.
func ValidateURL(raw string) error {
    // Parse the URL string.
    // This can panic on malformed input in older Go versions,
    // though modern Go is more robust. Fuzzing catches regressions.
    u, err := url.Parse(raw)
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }

    // Check the scheme.
    if u.Scheme != "https" {
        return fmt.Errorf("scheme must be https, got %s", u.Scheme)
    }

    // Check for empty host.
    if u.Host == "" {
        return fmt.Errorf("host is empty")
    }

    return nil
}

// FuzzValidateURL finds inputs that cause panics or unexpected behavior.
func FuzzValidateURL(f *testing.F) {
    // Seed with realistic URLs to guide the fuzzer.
    // These inputs help the fuzzer reach the validation logic quickly.
    f.Add("https://example.com/path")
    f.Add("https://user:pass@host.com")
    f.Add("http://insecure.com")

    f.Fuzz(func(t *testing.T, raw string) {
        // Run the validator.
        // We expect errors for bad URLs, but never a panic.
        // If ValidateURL panics, the fuzzer reports it as a failure.
        _ = ValidateURL(raw)
    })
}

The seeds include valid HTTPS URLs, a URL with credentials, and an insecure HTTP URL. This helps the fuzzer explore the different branches of the validation logic. The fuzzer will mutate these seeds to try breaking the parser or bypassing the checks. It might find inputs that cause url.Parse to behave unexpectedly, or strings that trigger edge cases in the scheme comparison.

Convention aside: fuzz tests should be deterministic in their reporting. If a panic occurs, the fuzzer saves the input. You can run go test -run=FuzzValidateURL to replay the corpus and verify the fix. The corpus acts as a regression suite for the bugs the fuzzer found.

Seed your corpus with real data. The fuzzer mutates what you give it.

Pitfalls and gotchas

Fuzzing is powerful, but it has traps. The most common mistake is fuzzing code that depends on external state. If your function makes network calls or reads from a database, the fuzzer will slow down dramatically and produce flaky results. Fuzzing should target pure functions or functions that mock their dependencies.

Another pitfall is ignoring the corpus. If you run the fuzzer and find a bug, fix the bug, but don't save the failing input, you risk regressing. The fuzzer saves the input automatically, but you need to commit it. Review the fuzz/ directory regularly. The inputs there are your new test cases.

Compiler errors can trip you up if the signature is wrong. If you write func FuzzMyTest(t *testing.T), the compiler rejects the program with a signature mismatch error. The fuzz function must take *testing.F. If you forget to call f.Fuzz, the compiler complains with an error about the fuzz function not being called. These errors are straightforward, but they stop the build until fixed.

Runtime panics are the goal. The fuzzer catches panics and reports them. If your code uses recover to handle panics silently, the fuzzer won't see them. Make sure your fuzz target doesn't swallow panics. If you need to test error handling, check the return value explicitly.

Fuzzing is a hammer. Don't use it on brittle code that depends on external state.

When to use fuzzing

Fuzzing fits into your testing strategy alongside unit tests and integration tests. It excels at finding input-related bugs. Use it where the input space is large and untrusted.

Use fuzzing when you have untrusted input like user data, file contents, or network packets. Use fuzzing when you are parsing complex formats like JSON, XML, or binary protocols. Use fuzzing when you are implementing cryptographic primitives or hash functions where edge cases can break security. Use unit tests when you need to verify specific business logic with deterministic inputs. Use integration tests when you need to verify interactions between services or databases. Use manual code review when you are checking for security policies or architectural decisions.

Fuzzing complements unit tests. It doesn't replace them.

Where to go next