| name | go-race-and-memory-model |
| description | Guides what a data race actually is in Go and how to detect it — a data race (two goroutines touch the same memory concurrently, at least one writing, with no happens-before edge) is undefined behavior, not a stale read; happens-before comes only from channels, mutexes, Once, WaitGroup, and atomics, never from a plain shared variable or a time.Sleep; "it passed once" is not proof; the race detector (go test/build/run -race) has no false positives but only catches races that actually execute, so run it in CI; and testing/synctest (1.25) gives deterministic concurrency tests with a fake clock. Auto-invokes when writing or editing concurrent access to shared variables, maps, or slices, reviewing goroutines for safety, or on "is this a data race" / "why does this only fail sometimes" / "concurrent map writes" / "set up -race in CI" requests. Owns the race concept and -race; routes fixes to go-sync-primitives, go-channels-select, and go-context. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Race and Memory Model
"Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access."
— The Go Memory Model
"If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don't be clever."
— The Go Memory Model
"It will not issue false positives, so take its warnings seriously."
— Introducing the Go Race Detector
A data race is not a style problem or a "sometimes-stale value." It is undefined behavior: the Go memory model gives a program with a race no guarantees at all. This skill owns two things — what a data race is (the memory model: happens-before, why "it passed once" proves nothing) and how to catch it (go test -race, CI, testing/synctest). It does not own the fixes: the locking primitives are go-sync-primitives, the channel handoff is go-channels-select, and cancellation is go-context.
1. The Rules, and Where Each Is Stated
Every rule below is a hard fact of the memory model or the race detector's documented behavior, not a preference.
| Rule | The mechanic | Source |
|---|
| Serialize shared access | Concurrent access to shared data must go through a synchronization primitive | "Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access" (Memory Model) |
| A data race is UB | A racy program is incorrect, not "eventually consistent" | "Programs with races are incorrect and can exhibit non-sequentially consistent executions" (Memory Model) |
| Definition of a data race | Concurrent read/write or write/write, at least one non-synchronizing, unordered by happens-before | "A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package" (Memory Model) |
| Happens-before is the only ordering | Two ops are ordered only if a synchronization edge connects them | "The happens before relation is defined as the transitive closure of the union of the sequenced before and synchronized before relations" (Memory Model) |
| Channels synchronize | A send is ordered before the corresponding receive completes | "A send on a channel is synchronized before the completion of the corresponding receive from that channel" (Memory Model) |
| Mutexes synchronize | Unlock n is ordered before Lock m returns | "call n of l.Unlock() is synchronized before call m of l.Lock() returns" (Memory Model) |
| Atomics synchronize | An observed atomic op is ordered before the observer | "if the effect of an atomic operation A is observed by atomic operation B, then A synchronizes before B" (sync/atomic) |
-race has no false positives | Every warning is a real race | "It will not issue false positives, so take its warnings seriously" (Race Detector) |
-race only finds executed races | It is a dynamic tool; coverage matters | "The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed" (Race Detector article) |
2. What a Data Race Is — and Why It Is Undefined Behavior
The spec's definition is precise: "A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package" (Memory Model). Three conditions, all required: same location, concurrent (unordered by happens-before, §3), and at least one write.
The dangerous misconception is that a race just gives a slightly stale read. It does not. A racy Go program is incorrect and the runtime owes it nothing: "Programs with races are incorrect and can exhibit non-sequentially consistent executions. In particular, note that a read r may observe the value written by any write w that executes concurrently with r. Even if this occurs, it does not imply that reads happening after r will observe writes that happened before w" (Memory Model). The implementation is even allowed to abort: "An implementation may always react to a data race by reporting the race and terminating the program" (Memory Model). Two goroutines racing on a map do exactly that — fatal error: concurrent map writes, an unrecoverable crash.
So the memory model's headline guidance is blunt — serialize, and stop being clever: "If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don't be clever" (Memory Model). The fix is never "reorder the reads" or "add a time.Sleep"; it is "put a synchronization edge between the accesses."
var n int
go func() { n++ }()
go func() { n++ }()
var n atomic.Int64
go func() { n.Add(1) }()
go func() { n.Add(1) }()
The detector confirms this exact n++ is a race — see §5 for the captured WARNING: DATA RACE.
3. Happens-Before: What Establishes Ordering, and What Does Not
Two memory accesses are "concurrent" unless a happens-before edge orders them: "The happens before relation is defined as the transitive closure of the union of the sequenced before and synchronized before relations" (Memory Model). Within one goroutine, program order gives you that ordering for free. Across goroutines, only a synchronization operation does. The complete list of edge-makers:
- Channel send/receive — "A send on a channel is synchronized before the completion of the corresponding receive from that channel"; and "A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel" (Memory Model). Mechanics owned by
go-channels-select.
- Mutex Lock/Unlock — "for any
sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() is synchronized before call m of l.Lock() returns" (Memory Model).
sync.Once — "The completion of a single call of f() from once.Do(f) is synchronized before the return of any call of once.Do(f)" (Memory Model).
sync.WaitGroup — a Wait that returns is ordered after the Done calls that released it.
- Atomics — "if the effect of an atomic operation A is observed by atomic operation B, then A synchronizes before B" (sync/atomic).
What does not establish happens-before, no matter how it looks:
- A plain shared variable. Reading a
bool flag another goroutine wrote, or an int, is not synchronization — the read may never observe the write, or may observe a torn value.
time.Sleep. Sleeping "long enough" for the other goroutine to finish creates no ordering edge; it only makes the race less likely to be observed, which is strictly worse than failing loudly.
- A
fmt.Println or any incidental call. Side effects are not synchronization.
var done bool
go func() { work(); done = true }()
for !done {
time.Sleep(time.Millisecond)
}
done := make(chan struct{})
go func() { work(); close(done) }()
<-done
4. "It Passed Once" Is Not Proof
A data race is timing-dependent, so a racy program can pass tests thousands of times and corrupt memory on the run that matters. "Works on my machine" and "the test is green" are evidence of nothing about race-freedom — a different CPU count, scheduler decision, compiler version, or production load can reorder the accesses and expose the bug. The memory model says the racy program was incorrect the whole time; you simply hadn't observed the failure yet.
This is why the standard of proof for concurrent code is not "the test passed" but "the test passed under -race, with the concurrent paths actually exercised." A green test without -race tells you the happy interleaving works; it says nothing about the others. Treat any "why does this only fail sometimes / only in CI / only under load" report as a race until -race clears it.
5. The Race Detector: -race
Go ships a dynamic race detector. Add -race to the build, test, or run command:
go test -race ./...
go build -race ./...
go run -race main.go
Its two defining properties make it trustworthy but not a proof of absence:
- No false positives. "It will not issue false positives, so take its warnings seriously" (Race Detector). A
WARNING: DATA RACE is always a real bug — never wave one away.
- Only catches races that execute. "The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed" (Race Detector article); "the race detector can detect race conditions only when they are actually triggered by running code, which means it's important to run race-enabled binaries under realistic workloads" (Race Detector). Coverage is the limiting factor: "it is only as good as your tests."
Running the racy counter from §2 under -race produces (captured from go test -race, Go 1.26):
==================
WARNING: DATA RACE
Read at 0x00c00010e338 by goroutine 11:
raceverify.(*RacyCounter).Inc()
.../counter.go:14 +0x7e
Previous write at 0x00c00010e338 by goroutine 9:
raceverify.(*RacyCounter).Inc()
.../counter.go:14 +0x90
...
testing.go:1712: race detected during execution of test
--- FAIL: TestRacyCounter (0.00s)
Replacing the plain int with a sync.Mutex or an atomic.Int64 (the §2 fix) makes the same test pass -race clean — --- PASS: TestMutexCounter / --- PASS: TestAtomicCounter. The detector reports the exact file:line of both conflicting accesses, which is why it is the fastest way to localize a race.
6. -race Is a Test/CI Tool, Not a Production Setting
Instrumentation is not free: "for a typical program, memory usage may increase by 5-10x and execution time by 2-20x" (Race Detector article); "race-enabled binaries can use ten times the CPU and memory, so it is impractical to enable the race detector all the time" (Race Detector). So the policy is:
- Run
go test -race ./... in CI as a required gate. It is the single highest-value concurrency check you can automate. Wiring it into the pipeline is owned by go-tooling-and-static-analysis.
- Point it at the concurrent paths. Load tests and integration tests are the best candidates: "Load tests and integration tests are good candidates, since they tend to exercise concurrent parts of the code" (Race Detector). A unit test that never starts a second goroutine teaches the detector nothing.
- Optionally canary it in production. "Another approach using production workloads is to deploy a single race-enabled instance within a pool of running servers" (Race Detector) — but never run the whole fleet instrumented.
7. testing/synctest for Deterministic Concurrency Tests (Go 1.25)
The reason concurrency tests are flaky is usually a real-time time.Sleep standing in for synchronization. testing/synctest removes the wall clock from the equation. It "provides support for testing concurrent code" (testing/synctest), graduated to stable in 1.25 — "This package was first available in Go 1.24 under GOEXPERIMENT=synctest ... The experiment has now graduated to general availability" (Go 1.25).
synctest.Test(t, f) runs f in an isolated bubble where "time is virtualized: time package functions operate on a fake clock and the clock moves forward instantaneously if all goroutines in the bubble are blocked" (Go 1.25). synctest.Wait() "blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked" (testing/synctest) — so you wait for a state, not a duration.
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := make(chan struct{})
go func() { time.Sleep(time.Hour); close(done) }()
synctest.Wait()
<-done
})
}
This skill owns synctest only as the answer to flaky-sleep race tests; go-testing-advanced owns it as a general testing facility (alongside fuzzing and benchmarks). Use them together: synctest makes the interleaving deterministic, -race proves that interleaving is race-free.
8. The Classic False-Confidence Patterns
Each is a way the code looks synchronized but is not. Depth and wrong/right code in references/common-mistakes.md:
- Concurrent map write — two goroutines writing one
map is not a race that returns garbage, it is a fatal error: concurrent map writes crash you cannot recover.
time.Sleep as synchronization — sleeping to "let the goroutine finish" creates no happens-before edge (§3); it hides the race instead of fixing it.
- Unprotected shared counter —
c.n++ from many goroutines is the canonical race (§2, §5).
- Reading a flag written by another goroutine — a plain
bool/int read is not atomic and not ordered; use atomic.Bool or a channel.
- "It passed once, ship it" — green tests without
-race prove nothing (§4).
- No
-race in CI — the one automated check that finds these, left off.
- Appending to a shared slice — concurrent
append races on the length and the backing array.
- Double-checked locking without atomics — the unlocked "fast path" read races the locked write; use
sync.Once or an atomic.
9. Who Suffers When Races Are Ignored
The victim is never the author at write time, because the racy run usually isn't theirs:
- The on-call engineer paged at 3am by
fatal error: concurrent map writes — a crash that only triggers under production concurrency, never reproduced locally, with a stack trace pointing at a map the author "knew" was single-writer.
- The teammate who burns two days on a "heisenbug" that vanishes whenever they add a print or a debugger — because the timing change hid the race, exactly as
time.Sleep did for the author who shipped it.
- The user whose balance, cart, or counter is silently corrupted by a torn read the memory model explicitly permits — no crash, no log, just a wrong number that a stale-read mental model said was impossible.
"Don't be clever" (Memory Model) is the empathy rule here: the clever lock-free shortcut, or the time.Sleep that "fixes" the flake, offloads an undefined-behavior bug onto whoever is on call when the scheduler finally interleaves it the wrong way. Serialize the access and let -race keep you honest.
10. Routing to Related Skills
This skill owns the race concept and the -race detector. The fixes and the surrounding tooling live elsewhere:
go-idiomatic-discipline — the policy root; racing on shared state is the "fighting Go / not correct" failure this skill detects.
go-sync-primitives — the fix by locking: sync.Mutex/RWMutex, sync.Once, and the typed sync/atomic values that make an access synchronizing.
go-channels-select — the fix by communication: why a send/receive is a happens-before edge, and "share memory by communicating."
go-context — cancellation as the way to stop the goroutine whose shared access would otherwise race.
go-concurrency-goroutines — goroutine lifetime/ownership; a leaked goroutine is often the unexpected second writer.
go-testing-advanced — owns testing/synctest as a testing facility (and fuzz/benchmarks); this skill owns it only as the deterministic-race-test answer. Cross-link, don't duplicate.
go-tooling-and-static-analysis — wiring go test -race into CI, plus go vet analyzers (e.g. waitgroup) that catch some races statically.
11. Reference Files
High-frequency false-confidence patterns in LLM-generated concurrent Go, each with wrong/right code and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml