The Test That Only Fails Under -race
A metrics test passes locally for weeks, then a colleague runs it with one flag and gets a different reality:
func TestHits(t *testing.T) {
var hits int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
hits++
}()
}
wg.Wait()
if hits != 100 {
t.Fatalf("got %d hits, want 100", hits)
}
}
go test -race ./...
==================
WARNING: DATA RACE
Write at 0x00c000124000 by goroutine 8:
example.com/app.TestHits()
/work/hits_test.go:9 +0x41
Previous read at 0x00c000124000 by goroutine 7:
example.com/app.TestHits.func1()
/work/hits_test.go:8 +0x6c
Goroutine 8 (running) created at:
example.com/app.TestHits()
/work/hits_test.go:6 +0x9a
==================
FAIL
exit status 1
Read the report as a story rather than noise. The address 0x00c000124000 is the shared memory — the hits variable. "Write at ... by goroutine 8" and "Previous read ... by goroutine 7" are the two conflicting accesses, each with a full stack: line 9 is the write half of hits++, line 8 is its read half, line 6 is where the goroutine was created. The detector has directly told you which lines interleave and on which address. Compare that with a production symptom like "the count was occasionally low" — the report turns a heisenbug into a diff.
The exit code is part of the design: a detected race fails the test binary, so go test -race slots into CI as a gate with no extra plumbing. The same flag works everywhere you run Go code:
go test -race -count=20 ./... # re-run 20 times — races are timing-dependent
go run -race ./cmd/server # run the real binary under detection
go test -race -run TestHits ./ # one test, one report
The -count=20 matters because the detector only reports conflicts it observes during the run. A race that needs a particular interleaving stays invisible under one execution and surfaces on the ninth. First command in a flaky-concurrency investigation: go test -race -count=50 on the suspicious package.
What the Detector Sees — and What It Deliberately Does Not
The race detector instruments memory: the compiler rewrites the binary so every access to shared memory is checked against shadow state tracking the last synchronized access per memory region. Two properties follow directly from that design, and both govern how you should interpret results.
It finds data races, and only data races. The definition is precise: two goroutines access the same memory location, at least one is a write, and no synchronization (lock, channel operation, WaitGroup edge, atomic) orders them. It does not find logic errors that are race-free by the memory model — a wrong value computed under a correct lock, a leaked resource, an off-by-one inside one goroutine, or a protocol bug where everyone takes the right lock on the wrong object. It also will not flag time.Sleep-based "synchronization," because sleep creates no ordering — your code still races even when the test passes.
Detection is probabilistic and workload-bound. The report exists only if both conflicting accesses actually execute during a run. Coverage decides everything:
go test -race ./... # one scheduling lottery ticket
go test -race -count=50 ./... # fifty tickets
GOTRACEBACK=all go test -race # full stacks if you need more detail
The corollary interviewers like: "no races reported" proves only that none manifested in the paths you exercised. The honest statement is "the detector saw no race in these runs" — which is why a race-clean CI job over tests that never touch the concurrent path carries little weight, and why the race-prone code path needs a test written specifically to hit it.
Cost decides where it runs. Instrumentation adds roughly 2–10× time overhead and several times the memory, because every access checks shadow state. That is acceptable for unit tests in CI and usually unacceptable for production hot paths or benchmarks — benchmark numbers measured under -race are meaningless:
go test -race ./... # correctness: yes, in CI
go test -bench=. ./... # performance: never with -race
Some teams do run race-enabled binaries in production for a canary window (the flag is available on supported platforms), but it is a diagnostic choice with real CPU and memory cost, not a default. For live profiling you have lighter tools — CPU and Runtime Profiling with pprof — while -race stays the correctness gate. And what it enforces is precisely the ordering rules of the Go memory model, the subject of Scheduling and the Happens-Before Relation.
From Report to Fix: Choosing the Right Repair
A report tells you where two accesses collide; choosing the repair means deciding what invariant the code was pretending to hold. Three fixes cover almost everything, and the choice is determined by the state, not by taste.
Multi-field or multi-step invariant → mutex. A session map read and written across a function is a struct-wide invariant; one lock covers all of it:
type Sessions struct {
mu sync.Mutex
m map[string]Session
}
func (s *Sessions) Get(id string) (Session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.m[id]
return sess, ok
}
func (s *Sessions) Put(id string, sess Session) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[id] = sess
}
Every access — reads included — goes through the same lock. Half the races in review come from a reader deciding it "only reads" and skipping the lock.
A map touched by two goroutines → mutex, full stop. Go maps are not safe for concurrent use, and concurrent writes can crash the runtime rather than merely lose data. No atomic wraps a map; the options are a lock or giving the map to exactly one goroutine.
Single counter or flag → atomic. One machine word updated with read-modify-write is what atomic exists for — hits.Add(1) replaces the racing hits++ with one indivisible operation, no lock, no critical section. The upcoming topic draws the full line between locks and atomics.
var hits atomic.Int64
hits.Add(1) // was: hits++
n := hits.Load() // was: plain read of a racing variable
Ownership → channel. When the race came from two goroutines touching the same structure, sometimes the better fix is making sure only one goroutine ever touches it, and passing work in as values. The report of who-writes-what then has a single answer by construction, not by discipline.
Verification is the same command that found the problem: go test -race -count=50 on the test that reproduced it goes green, and — more importantly — the invariant holds under stress. Resist the reflex fixes that do not repair anything: adding a sleep (creates no ordering), bumping the loop count down (hides the interleave), or copying the variable locally (the read still races). The detector flags missing ordering; only a lock, an atomic, a channel edge, or WaitGroup edge supplies it. When the fix is a lock, the details — critical-section size, when RWMutex pays, what atomics cannot do — are Mutexes, RWMutex, and Atomics.
Quiz
1. A test passes with `go test` but fails with `go test -race`. What has the flag changed?
2. `go test -race ./...` reports no races on a code path your tests never execute. What can you conclude?
3. The report shows two goroutines both writing the same map key. What is the correct fix?
4. Where should -race run, and where should it be avoided?
Flashcards
Question
What three conditions define a data race as the detector understands it?
Click to reveal answer
Answer
Two goroutines touch the same memory location, at least one access is a write, and no synchronization edge (mutex, channel, WaitGroup, atomic) orders the two accesses. Miss any one condition and the detector stays silent.
Question
How should you read the two stack traces in a DATA RACE report?
Click to reveal answer
Answer
The first trace is the write (or most recent access) with its line and goroutine number; the second, labeled 'Previous read/write', is the conflicting access from the other goroutine. Together with the 'created at' traces they identify the exact lines and spawn points to inspect — the address shows what memory is shared.
Question
Why can -race pass and the bug still be real?
Click to reveal answer
Answer
Detection is dynamic: both conflicting accesses must execute in the same run under an observed interleaving. A race needing a rare schedule stays hidden. Re-run with -count=50 and write a test that exercises the concurrent path before trusting a clean report.
Question
What does the race detector NOT find?
Click to reveal answer
Answer
Logic errors that are race-free under the memory model: wrong values computed under correct locking, resource leaks, ordering bugs where everyone holds the right lock but acts on stale assumptions, and sleep-based 'sync' that never had an edge. It only flags unsynchronized conflicting memory accesses.
Question
What are the costs of -race and where does that shape its placement?
Click to reveal answer
Answer
Roughly 2-10x slowdown and several times the memory from per-access shadow-state checks. So: always in CI test jobs and targeted reproduction, never in benchmarks (numbers become meaningless), and only deliberately in production (canary windows), with pprof used for regular performance work.
Revision Notes
Key Takeaways
- 1. -race instruments memory and reports the first observed conflict with both stack traces; it changes measurement, not your code's correctness
- 2. A clean race run is evidence about exercised paths only — pair it with -count and tests that hit the concurrent path
- 3. The detector enforces the memory model's ordering rules: missing edges show up as races, sleep never supplies one
- 4. Fixes are chosen by what is shared: mutex for invariants, atomic for one word, channel for ownership
- 5. Keep -race in CI tests, out of benchmarks, and treat production use as a costly canary decision
Interview Tips
- • "How do you find data races in Go?" — `go test -race` in CI plus `-count=50` for anything timing-sensitive; explain that it is dynamic, so test design determines what it can see
- • "Walk me through a DATA RACE report" — identify the address, both accesses and their lines, the creation traces, then say how the fix choice follows from what the state is
- • "When does -race miss a bug?" — unexercised paths and rare interleavings; follow with what you do about it (targeted tests, repetition, load)
- • "Race in production code — your response?" — reproduce locally under -race, fix with the right primitive (mutex/atomic/ownership), verify with repeated tests, and discuss whether a canary race build is warranted
Cheat Sheet
The Race Detector
Run it:
| Goal | Command |
|---|---|
| CI correctness gate | go test -race ./... |
| Chase a flake | go test -race -count=50 ./... |
| One test | go test -race -run TestX ./ |
| Real binary | go run -race ./cmd/app |
| Benchmarks | go test -bench=. ./... — no -race |
Reading a report: address = shared variable; "Write/Read at ... by goroutine N" + "Previous ..." = the conflicting pair; "created at" = spawn points. Exit code fails the build → CI gate for free.
Detector scope: finds unsynchronized same-address conflicts where one access writes. Dynamic and probabilistic — clean output means only 'not observed in these runs'. Costs 2-10x time, several-x memory.
Fix decision:
| State being shared | Repair |
|---|---|
| struct/map/multi-step invariant | mutex around ALL accesses |
| single counter or flag | atomic.Int64 / atomic.Bool |
| ownership ambiguous | channel — one goroutine owns it |
| never fixes it | sleep, lower loop counts, local copies |
Escalation: race found → fix → go test -race -count=50 to confirm → then learn why the lock works in Mutexes, RWMutex, and Atomics.
Continue Learning
Before You Start
- No prerequisites listed.