Fuzz Testing Go Code: Native go test -fuzz and dvyukov/go-fuzz
Go 1.18 shipped something remarkable: built-in fuzz testing integrated directly into go test. No external tools, no compilation flags, no special setup — just write a FuzzXxx function and run go test -fuzz. This guide covers native Go fuzzing, the older dvyukov/go-fuzz library, and how to write fuzz targets that actually find bugs.
Native Go Fuzzing (Go 1.18+)
How It Works
Go's built-in fuzzer uses coverage-guided fuzzing, the same technique as AFL++ and libFuzzer. It instruments your code at compile time, tracks which branches each input exercises, and evolves the corpus toward higher coverage.
The key difference from unit tests: fuzz tests run indefinitely (until you stop them or set a time limit), continually generating new inputs.
Writing a Fuzz Target
A fuzz function has the signature FuzzXxx(f *testing.F):
// parser_fuzz_test.go
package parser_test
import (
"testing"
"github.com/yourorg/myparser"
)
// FuzzParseConfig tests that ParseConfig never panics on arbitrary input
func FuzzParseConfig(f *testing.F) {
// Seed corpus: real inputs that should work
f.Add(`{"key": "value", "count": 42}`)
f.Add(`{}`)
f.Add(`{"nested": {"deep": {"deeper": "value"}}}`)
// The fuzz function receives the generated input
f.Fuzz(func(t *testing.T, data string) {
// ParseConfig should handle ANY string without panicking
// It may return an error, but it must not panic
config, err := myparser.ParseConfig(data)
if err != nil {
return // Parse errors are expected and OK
}
// If parse succeeded, serialize should work too
serialized, err := config.Serialize()
if err != nil {
t.Errorf("ParseConfig succeeded but Serialize failed: %v", err)
return
}
// Round-trip: re-parse the serialized output
config2, err := myparser.ParseConfig(serialized)
if err != nil {
t.Errorf("Serialize output not re-parseable: %v\nInput: %q\nSerialized: %q",
err, data, serialized)
}
_ = config2
})
}Running Fuzz Tests
# Run unit tests only (not fuzzing mode — runs corpus items as unit tests)
go <span class="hljs-built_in">test ./...
<span class="hljs-comment"># Start fuzzing (runs indefinitely)
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig
<span class="hljs-comment"># Fuzz for a specific duration
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig -fuzztime=5m
<span class="hljs-comment"># Fuzz a specific package
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig ./pkg/parser/
<span class="hljs-comment"># Run with verbose output
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig -v -fuzztime=1mOutput during fuzzing:
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 84953 (28317/sec), new interesting: 43 (total: 46)
fuzz: elapsed: 6s, execs: 199946 (38331/sec), new interesting: 51 (total: 54)
fuzz: elapsed: 9s, execs: 317259 (39104/sec), new interesting: 54 (total: 57)execs/sec: How fast the fuzzer is running (target executions per second)new interesting: Inputs that triggered new code coveragetotal: Total corpus size
When a Crash is Found
--- FAIL: FuzzParseConfig (4.23s)
Failing input written to testdata/fuzz/FuzzParseConfig/1a2b3c4d
To re-run: go test -run=FuzzParseConfig/1a2b3c4d
FAILThe failing input is saved to testdata/fuzz/FuzzParseConfig/. This is committed to your repo and automatically becomes a regression test — go test (without -fuzz) runs it on every test invocation.
# Reproduce the failure
go <span class="hljs-built_in">test -run=FuzzParseConfig/1a2b3c4d -vMulti-Input Fuzz Functions
Your fuzz function can take multiple parameters:
func FuzzNetworkParser(f *testing.F) {
// Seed with (host, port) pairs
f.Add("localhost", 8080)
f.Add("192.168.1.1", 443)
f.Add("example.com", 80)
f.Fuzz(func(t *testing.T, host string, port int) {
addr := net.JoinHostPort(host, strconv.Itoa(port))
// Whatever you're testing with (host, port)
result := ParseAddress(addr)
_ = result
})
}Supported types: string, []byte, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, rune, byte.
Testing the Go Standard Library Style
Go's standard library uses this pattern extensively. Here's how the encoding/json package is fuzz-tested:
func FuzzUnmarshal(f *testing.F) {
f.Add(`null`)
f.Add(`true`)
f.Add(`{}`)
f.Add(`[]`)
f.Add(`{"a":1}`)
f.Fuzz(func(t *testing.T, b []byte) {
var i interface{}
if err := json.Unmarshal(b, &i); err != nil {
return
}
// Round-trip test
out, err := json.Marshal(i)
if err != nil {
t.Fatalf("marshal after unmarshal failed: %v", err)
}
var i2 interface{}
if err := json.Unmarshal(out, &i2); err != nil {
t.Fatalf("re-unmarshal failed: %v\nInput: %q\nMarshaled: %q",
err, b, out)
}
})
}Effective Seed Corpus
The seed corpus (in f.Add() calls) matters enormously:
func FuzzHTTPRequestParser(f *testing.F) {
// Minimal valid requests covering different code paths
f.Add("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
f.Add("POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello")
f.Add("GET /path?q=hello+world HTTP/1.0\r\n\r\n")
// Edge cases you already know about
f.Add("GET / HTTP/1.1\r\nHost: example.com\r\n" + strings.Repeat("X: y\r\n", 100) + "\r\n")
f.Add("OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n")
f.Fuzz(func(t *testing.T, raw string) {
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(raw)))
if err != nil {
return
}
defer req.Body.Close()
// Verify invariants on successfully parsed request
if req.Method == "" {
t.Error("successfully parsed request has no method")
}
})
}Testdata Corpus from Files
For binary formats, populate the corpus from files:
# Corpus stored in testdata/fuzz/FuzzJPEGParser/
<span class="hljs-built_in">mkdir -p testdata/fuzz/FuzzJPEGParser
<span class="hljs-comment"># Copy real JPEG files as seeds
<span class="hljs-keyword">for f <span class="hljs-keyword">in testdata/samples/*.jpg; <span class="hljs-keyword">do
<span class="hljs-built_in">cp <span class="hljs-string">"$f" <span class="hljs-string">"testdata/fuzz/FuzzJPEGParser/$(basename $f)"
<span class="hljs-keyword">doneGo automatically uses these files as seed corpus for FuzzJPEGParser.
dvyukov/go-fuzz (Pre-1.18 and Custom Mutators)
Before Go 1.18, dvyukov/go-fuzz was the standard fuzzer for Go. It's still useful for:
- Go versions before 1.18
- Custom mutation strategies (Sonar mode)
- Some Go version-specific behavior
Installation
go install github.com/dvyukov/go-fuzz/go-fuzz@latest
go install github.com/dvyukov/go-fuzz/go-fuzz-build@latestWriting a go-fuzz Target
// fuzz.go — must be in a separate file with build tag
//go:build gofuzz
package mypackage
func Fuzz(data []byte) int {
_, err := ParseMyFormat(data)
if err != nil {
// Return 0 to tell go-fuzz this is uninteresting
return 0
}
// Return 1 to tell go-fuzz this input is interesting (add to corpus)
return 1
}Building and Running
# Build instrumented binary
go-fuzz-build -o fuzz.zip github.com/yourorg/yourpackage
<span class="hljs-comment"># Run (corpus in workdir/corpus/, crashes in workdir/crashers/)
go-fuzz -bin=fuzz.zip -workdir=fuzz-workdir/Migration to Native Fuzzing
If you have go-fuzz targets, migrating to native fuzzing is straightforward:
// Old go-fuzz target
func Fuzz(data []byte) int {
_, err := ParseMyFormat(data)
if err != nil {
return 0
}
return 1
}
// New native fuzz target
func FuzzMyFormat(f *testing.F) {
f.Add([]byte{0x00, 0x01, 0x02}) // Seed corpus
f.Fuzz(func(t *testing.T, data []byte) {
_, err := ParseMyFormat(data)
_ = err // Errors are expected
})
}Migrate: copy seed corpus files from fuzz-workdir/corpus/ to testdata/fuzz/FuzzMyFormat/.
Real-World Example: Fuzzing a URL Router
// fuzz_router_test.go
package router_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/yourorg/router"
)
func FuzzRouter(f *testing.F) {
// Seed with various URL patterns
f.Add("/")
f.Add("/users/123")
f.Add("/api/v1/products?page=1&limit=20")
f.Add("/files/../../../etc/passwd") // Path traversal attempt
f.Add("/api/" + strings.Repeat("x", 1000)) // Long path
// Create router
r := router.New()
r.GET("/users/:id", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
r.GET("/api/v1/products", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
f.Fuzz(func(t *testing.T, path string) {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
// Router must never panic
r.ServeHTTP(w, req)
// Response code must be a valid HTTP status
code := w.Code
if code < 100 || code > 599 {
t.Errorf("invalid status code: %d for path: %q", code, path)
}
})
}CI Integration
# .github/workflows/fuzz.yml
name: Fuzzing
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * *'
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Fuzz ParseConfig
run: go test -fuzz=FuzzParseConfig -fuzztime=2m ./pkg/parser/
- name: Fuzz HTTPParser
run: go test -fuzz=FuzzHTTPParser -fuzztime=2m ./pkg/http/
- name: Check for new corpus entries
run: |
if git status --porcelain testdata/fuzz | grep -q '??'; then
git config user.name "Fuzzer Bot"
git config user.email "fuzzer@example.com"
git add testdata/fuzz/
git commit -m "chore: add fuzz corpus entries from CI"
git push
fiConnecting to Production Monitoring
Go fuzzing protects your parsing and processing code from malformed input. Production Go applications fail for different reasons: third-party dependencies, infrastructure issues, data edge cases that only appear at scale.
HelpMeTest monitors your live Go services with continuous end-to-end tests — 24/7 monitoring of the user-facing behavior that fuzzing can't cover. Write tests in plain English, get alerted immediately when something breaks. $100/month, no infrastructure to manage.
Summary
Go's built-in fuzzing is the lowest-friction entry point to fuzz testing in any language:
- Write a
FuzzXxx(f *testing.F)function - Add seed corpus with
f.Add() - Run
go test -fuzz=FuzzXxx -fuzztime=5m - Failing inputs are saved as regression tests automatically
Start with your input parsers, network protocol handlers, and any code that processes user-provided data. Go fuzzing will find bugs that your unit tests have missed — guaranteed.