The Go Memory Model in Plain Language for Developers
The Go memory model says when one goroutine is guaranteed to see another's writes. See happens-before in practice, a race the detector catches and four fixes.

The Go memory model sounds like a topic for people who write compilers, and it is the one piece of theory that decides whether concurrent Go code is correct. It answers a question that looks trivial: if one goroutine writes a variable and another reads it, when is the reader guaranteed to see the new value? The answer is not "after the write happened in time". It is "if there is a chain of synchronization from the write to the read", and without that chain you get no guarantee at all. This article states the rule in plain words, shows a race caught by the detector with real output, and then repairs it four ways. It is part of the Go basics path, sits beside the runtime basics and the garbage collector, and is the theory behind Go concurrency patterns.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64), with the race detector enabled
The rule: happens-before
Within one goroutine, statements behave as if they run in the order you wrote them. Across goroutines there is no such order unless you create one. The Go memory model defines a partial order called happens-before, and one guarantee: a read of a variable may observe a write only if the write happens before the read, or there is no other write that races with it. When neither edge exists between a write and a read, they are concurrent, and the access is a data race. Races are not "slightly wrong" behavior. They are the point where the compiler and CPU are allowed to reorder, cache and combine operations in ways that no ordering of your source lines explains.
The synchronization operations create the edges. The list in the specification, in the form I use it:
| Operation | Edge it creates |
|---|---|
go f() | Everything before the go statement happens before f starts |
| Channel send | The send happens before the matching receive completes |
| Channel close | The close happens before a receive that returns because of it |
| Unbuffered receive | The receive happens before the matching send completes |
mu.Unlock() | Happens before the next mu.Lock() returns |
once.Do(f) | The return of f happens before any Do returns |
wg.Wait() | Every wg.Done() happens before Wait returns |
sync/atomic | An atomic write happens before an atomic read that observes it |
A goroutine's exit has no edge: nothing says that a goroutine finishing makes its writes visible to anyone, so waiting for it takes one of the operations above. That is the reason time.Sleep is never a synchronization tool. Sleeping long enough "works" until the machine is busy.
A race, caught
This is the classic form. A producer sets a value and then a flag. The consumer spins on the flag and then reads the value. It looks right and it is wrong:
package main
import "fmt"
var data int
var ready bool
func producer() {
data = 42
ready = true
}
func main() {
go producer()
for !ready {
}
fmt.Println(data)
}42
==================
WARNING: DATA RACE
Write at 0x0001042c7088 by goroutine 7:
main.producer()
example.test/lab/main.go:10 +0x40
Previous read at 0x0001042c7088 by main goroutine:
main.main()
example.test/lab/main.go:15 +0x34
Goroutine 7 (running) created at:
main.main()
example.test/lab/main.go:14 +0x28
==================
==================
WARNING: DATA RACE
Read at 0x0001042c7110 by main goroutine:
main.main()
example.test/lab/main.go:17 +0x50
Previous write at 0x0001042c7110 by goroutine 7:
main.producer()
example.test/lab/main.go:9 +0x28
Goroutine 7 (finished) created at:
...The race detector reports each racing pair: a write at one line of the producer and a read at one line of main, with the goroutines that made them and where they were created. There are two warnings, one for ready and one for data, and either would be enough to condemn the program. The reasoning the detector is enforcing is the one above. Nothing orders the write of data before the read in main, since there is no channel operation, lock or atomic between them. Memory ordering differs between CPU families, so a race that stays hidden on one can show up on another, and the compiler is also free to hoist the ready load out of the loop and make it spin forever. The fact that the program printed 42 on your machine tells you nothing.
Four fixes
Each fix replaces the unordered flag with a real edge. They are different tools, and each is the right one in some situation.
A channel. Closing a channel is the lightest signal: the close happens before the receive that observes it, so everything the producer wrote before closing is visible afterwards.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var data int
done := make(chan struct{})
go func() {
data = 42
close(done)
}()
<-done
fmt.Println(data)
var mu sync.Mutex
var wg sync.WaitGroup
count := 0
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(count)
var ready atomic.Bool
var value atomic.Int64
go func() {
value.Store(7)
ready.Store(true)
}()
for !ready.Load() {
}
fmt.Println(value.Load())
var once sync.Once
cfg := ""
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
once.Do(func() { cfg = "loaded" })
_ = cfg
}()
}
wg.Wait()
fmt.Println(cfg)
}42
100
7
loadedThe program ran under the race detector and printed four lines with no warnings. In order: the channel version published data to the main goroutine, so it read 42. The mutex version protected a shared counter that 100 goroutines incremented, reaching exactly 100, as mutexes in Go explain. The atomic version spins on an atomic.Bool, and because Go's atomics are sequentially consistent since Go 1.19, the store to value before ready.Store(true) is visible once ready reads true. And sync.Once guarantees that the one-time initialization of cfg finishes before any Do returns, so every goroutine sees the loaded value, the pattern in the sync Once family.
To choose among them: use a channel when the event is "this is done" or "here is the value", a mutex when several goroutines read and write shared state, an atomic for a single counter or flag, and Once for lazy initialization. For atomics, see atomic operations.
What a race can do
People hold two wrong beliefs about races: that they only corrupt the value being raced on, and that on a word-sized variable the worst case is reading an old value. For a single word like an int, the model does say a racy read observes some previously written value and not garbage. For multiword values the story is worse. A string is a pointer and a length, a slice is a pointer, a length and a capacity, and an interface is two words. A racy write to one can be seen half-done by a reader, producing a string whose length belongs to a different string, and that is a path to memory corruption that Go is otherwise designed to prevent. The concurrent map write that the runtime detects and kills the process for, fatal error: concurrent map writes, is the runtime defending itself against exactly that. The model's conclusion is blunt, and I agree with it: do not write racy programs and then reason about what they might do.
Finding races
The race detector is built into the toolchain, enabled with -race on go test, go run and go build. It instruments memory accesses and tracks happens-before edges at run time, and it reports pairs that raced during that execution. Two properties govern how you use it. It finds races only on paths that actually ran, so it is only as good as your tests, and a race-free run is evidence and not a proof. And it costs memory and time, the race detector article puts it at roughly five to ten times the memory and two to twenty times the CPU, which is why it belongs in CI and development and not in production. Run go test -race ./... on every change that touches goroutines. If a report points at a spot you believe is safe, the detector is almost always right and the belief is the thing to examine.
Where the model is not the problem
Not every concurrency bug is a race. A deadlock, a goroutine leak and a lost update that is correctly locked but logically wrong are all race-free and still wrong, and the detector will say nothing. Races between a goroutine and the outside world, a file, a database row, are not memory races, and the memory model does not cover them. And performance questions, such as whether an atomic or a mutex is faster, are separate: the model is about correctness, and the measurement is yours to make. Single-threaded programs, or programs where every shared value is handed over through a channel, can ignore the whole topic, which is the design that the Go proverbs recommend and the reason the standard advice is to share by communicating.


