# Go Race And Memory Model

> 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.

- Skill: `ctoth/go-race-and-memory-model` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-race-and-memory-model`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-race-and-memory-model/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-race-and-memory-model

---


# Go Race and Memory Model

> "Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access."
> — [The Go Memory Model](https://go.dev/ref/mem)

> "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](https://go.dev/ref/mem)

> "It will not issue false positives, so take its warnings seriously."
> — [Introducing the Go Race Detector](https://go.dev/blog/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](https://go.dev/ref/mem)) |
| 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](https://go.dev/ref/mem)) |
| 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](https://go.dev/ref/mem)) |
| 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](https://go.dev/ref/mem)) |
| 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](https://go.dev/ref/mem)) |
| 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](https://go.dev/ref/mem)) |
| 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](https://pkg.go.dev/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](https://go.dev/blog/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](https://go.dev/doc/articles/race_detector)) |

---

## 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](https://go.dev/ref/mem)). 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](https://go.dev/ref/mem)). 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](https://go.dev/ref/mem)). 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](https://go.dev/ref/mem)). The fix is never "reorder the reads" or "add a `time.Sleep`"; it is "put a synchronization edge between the accesses."

```go
// WRONG — two goroutines write the same int with no synchronization; this is a data race,
// undefined behavior, NOT "an int that's occasionally a bit behind"
var n int
go func() { n++ }()
go func() { n++ }()

// RIGHT — an atomic makes both writes synchronizing; the accesses are now ordered
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](https://go.dev/ref/mem)). 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](https://go.dev/ref/mem)). 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](https://go.dev/ref/mem)).
- **`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](https://go.dev/ref/mem)).
- **`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](https://pkg.go.dev/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.

```go
// WRONG — the writer sets a plain bool; the reader has no happens-before edge to it,
// so it may spin forever or read a half-written value. time.Sleep does not fix this.
var done bool
go func() { work(); done = true }()
for !done {              // racy read of done
	time.Sleep(time.Millisecond) // makes it "usually work" — not a synchronization edge
}

// RIGHT — a channel receive is synchronized-before the send that closed it
done := make(chan struct{})
go func() { work(); close(done) }()
<-done                   // happens-before edge: work() is visible after this returns
```

---

## 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:

```sh
go test -race ./...     # test packages with the detector on
go build -race ./...    # build an instrumented binary
go run -race main.go    # run a source file instrumented
```

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](https://go.dev/blog/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](https://go.dev/doc/articles/race_detector)); "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](https://go.dev/blog/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):

```text
==================
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](https://go.dev/doc/articles/race_detector)); "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](https://go.dev/blog/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](https://go.dev/blog/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](https://go.dev/blog/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](https://pkg.go.dev/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](https://go.dev/doc/go1.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](https://go.dev/doc/go1.25)). `synctest.Wait()` "blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked" ([testing/synctest](https://pkg.go.dev/testing/synctest)) — so you wait for a *state*, not a duration.

```go
// RIGHT — a test that exercises a 1-hour timeout with NO real sleep and NO flake
func TestTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		done := make(chan struct{})
		go func() { time.Sleep(time.Hour); close(done) }() // fake clock: instant
		synctest.Wait() // wait until the goroutine is durably blocked in Sleep
		<-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](https://go.dev/ref/mem)) 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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

