Race Detector: Running and Interpreting Output
Go's race detector instruments memory accesses at compile time to find unsynchronized concurrent reads and writes.
Run it in CI on packages that use goroutines; learn to read its reports to fix real bugs, not silence noise.
Summary
Enable with -race on go test, go run, or go build.
A data race is when two goroutines access the same memory location, at least one is a write, and there is no happens-before ordering.
Reports show goroutine stacks for both sides of the race.
Fix with mutexes, channels, atomics, or restructuring ownership - never "it passed once without -race."
Recipe
Quick-reference recipe card - copy-paste ready.
go test -race ./...
go run -race .
go build -race -o bin/app .
./bin/app # staging only - race builds are slower and use more memoryWhen to reach for this:
- Every CI pipeline for concurrent packages.
- After refactoring shared caches, singletons, or handler globals.
- When tests flake under parallel
-count=10. - Before blaming the scheduler for impossible counter values.
Working Example
Buggy code and typical detector output pattern:
package counter
import "sync"
var n int // BUG: unsynchronized
func Inc() { n++ }
func BrokenParallel(wg *sync.WaitGroup) {
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
Inc()
}()
}
}$ go test -race ./...
==================
WARNING: DATA RACE
Write at 0x... by goroutine 7:
example.com/counter.Inc()
example.com/counter.BrokenParallel.func1()
Previous read at 0x... by goroutine 6:
...
==================Fixed version:
var (
mu sync.Mutex
n int
)
func Inc() {
mu.Lock()
n++
mu.Unlock()
}What this demonstrates:
- Race report names functions and goroutine IDs on both sides.
Incand callers appear in stacks - start reading from your code frames.- Mutex establishes happens-before between increments.
- Re-run
-raceuntil clean.
Deep Dive
How It Works
- Compiler inserts happens-before tracking around sync primitives and channel ops.
- Runtime records memory access metadata (shadow memory).
- Conflicting unsynchronized accesses print a report with stack traces.
- Overhead: roughly 2-10x CPU and 5-10x memory - not for production serving.
- Coverage is per process - races need interleavings tests exercise.
Report Anatomy
| Section | Meaning |
|---|---|
Write at / Read at | Access type and address |
by goroutine N | Stack of one participant |
Previous read/write | Other participant stack |
Goroutine N created at | Where the goroutine started |
Fix Strategies
| Pattern | Fix |
|---|---|
| Shared map without lock | Mutex or pass ownership via channel |
| Check-then-act | Lock entire check+act or use atomic CAS |
| Lazy init race | sync.Once |
| Append to shared slice | Mutex or single writer goroutine |
Go Notes
# Flaky race hunt
go test -race -count=50 ./pkg
# CI snippet
go test -race -short ./...Gotchas
- Skipping -race in CI - Races ship to prod. Fix: mandatory
-racejob on main packages. - Fixing tests only by
-parallel 1- Hides races. Fix: synchronize code under test. - Ignoring races in third-party code - May still be your call path. Fix: upgrade dep or wrap with sync.
- Assuming atomics without
-raceclean - Logic races remain. Fix: atomics for simple counters; mutex for invariants. - Race-free but deadlock - Detector does not catch deadlocks. Fix: timeouts,
go test -timeout, deadlock detectors in staging.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
-race tests | Default concurrency verification | Production binaries |
Mutex + code review | Prevent races by design | Substitute for ever running -race |
go test -fuzz | Input-driven bugs | Data races without fuzz concurrency |
| Thread sanitizers (other langs) | N/A in Go | Go code - use built-in race detector |
FAQs
Does -race slow down tests a lot?
Yes - often several times slower.
Run on CI nightly or per-PR for affected packages if full suite is too slow.
Can races exist if tests pass without -race?
Yes - races are timing-dependent.
-race forces interleaving instrumentation to surface them.
Should I ship -race binaries?
No for latency-sensitive production.
Use staging or dedicated soak environments with -race builds.
What if the race is in init()?
Reports still show stacks.
Serialize init with sync.Once or avoid mutable globals.
Do channel ops avoid races on the value?
The handoff is synchronized, but mutating a struct after sending a pointer still races if other goroutines read it unsynchronized.
How do atomics interact with -race?
Proper atomic ops do not race on the same word.
Mixing atomics and mutex on overlapping data is still error-prone.
Can I suppress a race report?
No official suppression like some C tools.
Fix the bug or refactor test design.
Does -race work on all platforms?
Supported on linux/amd64, darwin/amd64, darwin/arm64, windows/amd64, and other listed ports.
Check go.dev docs for current platform list.
Why two stacks in the report?
A race needs two concurrent accesses.
Each stack is one goroutine's participation.
How does golangci-lint help?
Linters catch some race-prone APIs but do not replace -race.
Use both in CI.
Related
- Concurrency Basics - example 10 race test
- sync.Mutex, RWMutex & WaitGroup - primary fix tool
- Channels: Buffered, Unbuffered & Closing Rules - ownership handoff
- Concurrency in Go: Goroutines and Channels First - happens-before model
- Concurrency Fundamentals Best Practices - CI habits
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).