Go Memory Model

The Go memory model is the contract that specifies precisely when a read of a variable in one goroutine is guaranteed to observe a value written by a write in a different goroutine (go.dev/ref/mem). Its single most important promise is DRF-SC: any Go program that is data-race-free behaves exactly as if all its goroutines were interleaved onto one processor in some sequentially consistent order — so a correctly synchronized program never needs to reason about reordering, caches, or weak hardware memory at all. Equally important is what Go does not do for racy programs: unlike C and C++, a data race in Go is not undefined behavior. A racy Go program has a bounded set of bad outcomes — the compiler may not invent values “out of thin air” — which makes errant Go programs far more debuggable, even though the race is still a bug the implementation is permitted to detect and halt on.

The model was substantially rewritten and formalized in 2022 (the document still carries “Version of June 6, 2022”, verified unchanged against the spec as of Go 1.26.3, May 2026 — the Go 1.26 release notes record no memory-model change, per go.dev/doc/go1.26). Before then it was an informal collection of “happens-before” rules; the revision added a precise definition built on sequenced-before and synchronized-before relations, and — for the first time — fully specified the synchronization semantics of the sync/atomic package (Cox, Updating the Go Memory Model). This note teaches that formal model. The runtime mechanisms that implement the synchronization edges live in Channels in Go, sync.Mutex Internals, and Atomic Operations in Go; this note is the semantics those notes rely on.

Mental Model

flowchart TD
  subgraph G1["Goroutine 1"]
    W1["a = 1  (ordinary write)"]
    SW1["ch <- 0  (synchronizing write-like)"]
    W1 -->|"sequenced-before"| SW1
  end
  subgraph G2["Goroutine 2"]
    SR2["<-ch  (synchronizing read-like)"]
    R2["print(a)  (ordinary read)"]
    SR2 -->|"sequenced-before"| R2
  end
  SW1 -.->|"synchronized-before<br/>(receive observes send)"| SR2
  W1 ==>|"happens-before<br/>(transitive closure)"| R2

Diagram: how one synchronization point stitches two goroutines together. The insight: a write a = 1 becomes visible to a read print(a) in another goroutine only when there is a chain — sequenced-before within G1, then a synchronized-before edge across goroutines (here, send-observed-by-receive), then sequenced-before within G2. Happens-before is exactly the transitive closure of those two relations. Without a cross-goroutine synchronized-before edge, there is no happens-before, and the read may legally see any value.

The Formal Model

The 2022 model is built from three relations over memory operations. A memory operation is characterized by its kind (an ordinary read/write, or a synchronizing operation such as an atomic access, a mutex operation, or a channel operation), its location in the program, the variable accessed, and the values read or written (go.dev/ref/mem).

Sequenced before is a partial order within a single goroutine, defined by the language spec’s control-flow and expression-evaluation order. It captures program order: if statement A textually and control-flow-wise precedes statement B in the same goroutine, A is sequenced-before B.

Synchronized before is the cross-goroutine relation. It is derived from synchronizing operations: the spec defines a function W that maps each synchronizing read-like operation r to the synchronizing write-like operation it observes. If r observes w (written W(r) = w), then w is synchronized before r. Read-like operations are: an ordinary read, an atomic read, a mutex Lock, a channel receive. Write-like operations are: an ordinary write, an atomic write, a mutex Unlock, a channel send, a channel close. An atomic compare-and-swap is both.

Happens before is then defined as the transitive closure of the union of sequenced-before and synchronized-before. That single sentence is the whole model — everything else is consequences.

What happens-before buys you: visibility

The model’s payoff is the visibility rule for ordinary (non-synchronizing) reads. For an ordinary read r of variable x, the model requires that r observe a write w to x that is visible to r, where w is visible if and only if:

  1. w happens before r, and
  2. w does not happen before any other write w' to x that itself happens before r.

In words: a synchronized read sees the most recent write that happens-before it, with no intervening overwrite. This is the formal version of “your goroutine sees the writes the other goroutine made before its synchronization point.”

DRF-SC and the data-race definition

A data race is two memory operations on the same variable where (a) at least one is a write, (b) at least one is non-synchronizing (i.e. ordinary), and (c) they are unordered by happens-before — neither happens before the other. (The spec splits this into a read-write race and a write-write race, but the shape is identical.) Accesses that are all atomic (sync/atomic) are by definition synchronizing and so never race with each other.

The central guarantee — DRF-SC, “data-race-free implies sequential consistency” — states verbatim: “a Go program that is data-race-free can only have outcomes explained by some sequentially consistent interleaving of the goroutine executions.” If you never write a data race, you may reason about your program as a simple interleaving of goroutine steps, exactly as if there were one CPU. You never have to think about store buffers, cache coherence, or compiler reordering. That is the entire point of the model: it lets correct concurrent code be reasoned about simply.

What a data race does — and does not — do

This is where Go deliberately diverges from C/C++. In C/C++ a data race is undefined behavior: the compiler may assume it never happens and the program may do anything. Go instead gives racy programs defined-but-limited semantics (Cox, Updating the Go Memory Model):

  • For a variable no larger than a machine word, a racy read r must still observe some actual write w such that r does not happen-before w and no w' lies between them in happens-before. In short: a word-sized racy read returns a value that some real preceding-or-concurrent write actually wrote — a torn-but-real value, never garbage.
  • Acausal and “out of thin air” writes are explicitly disallowed. The compiler may not speculate a value into existence. (Java’s memory model has long struggled to formalize this prohibition; Go sidesteps the difficulty by keeping the model simple and conservative.)
  • For a variable larger than a machine word (an interface value, slice header, string header, or a wide struct), the model only encourages single-write semantics. Implementations are permitted to treat a wide access as several independent machine-word accesses in unspecified order. The spec spells out the consequence verbatim: “races on multiword data structures can lead to inconsistent values not corresponding to a single write. When the values depend on the consistency of internal (pointer, length) or (pointer, type) pairs, as can be the case for interface values, maps, slices, and strings in most Go implementations, such races can in turn lead to arbitrary memory corruption.”

So the honest summary is: a word-sized race gives you a wrong-but-bounded value; a race on an interface, slice, string, or map can tear the (pointer, type) or (pointer, length) pair and crash or corrupt memory. Never race on those. And the implementation may at any time, using the race detector, simply report the race and halt — go build -race does exactly that.

The Synchronization Rules

The model is only useful because the spec enumerates the concrete operations that create synchronized-before edges. Each rule below is the exact spec wording.

Initialization. If package p imports package q, the completion of q’s init functions happens before any of p’s begin; the completion of all init functions is synchronized before the start of main.main.

Goroutine creation. “The go statement that starts a new goroutine is synchronized before the start of the goroutine’s execution.” So everything sequenced-before the go statement is visible to the new goroutine — you may safely hand it data by closure.

Goroutine destruction. “The exit of a goroutine is not guaranteed to be synchronized before any event in the program.” A goroutine finishing creates no edge — you cannot learn that a goroutine wrote something merely because it exited. Wait on a channel, a WaitGroup, or a lock instead.

Channel send/receive. “A send on a channel is synchronized before the completion of the corresponding receive from that channel.” And: “The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed.” For unbuffered channels there is also the dual: “A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel” — the rendezvous is bidirectional. For buffered channels of capacity C: “The k-th receive on a channel with capacity C is synchronized before the completion of the k+C-th send.” This is the rule that makes a buffered channel usable as a counting semaphore.

Locks. “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.” For RWMutex, an RLock is synchronized after some prior Unlock, and the matching RUnlock is synchronized before the next Lock. A successful TryLock/TryRLock is equivalent to Lock/RLock; a failed one establishes no edge at all (and may, per the model, even fail when the mutex is unlocked).

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).” Every goroutine that returns from Do sees everything f did.

sync/atomic. This is the rule the 2022 revision added: “If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B. All the atomic operations executed in a program behave as though executed in some sequentially consistent order.” Go deliberately chose sequentially consistent atomics — equivalent to C++‘s seq_cst atomics and Java’s volatile — rather than the weaker acquire/release atomics of C++. Cox’s rationale: reasoning about multiple atomic variables (e.g. a lock-free fast path guarded by a flag) requires knowing their relative order, which acquire/release does not give; and hardware is converging on cheap sequential consistency anyway (ARMv8’s ldar/stlr), so the weaker model would only let buggy code accidentally pass on some CPUs (Cox, Updating the Go Memory Model).

Finalizers. A call to runtime.SetFinalizer(x, f) is synchronized before the finalization call f(x).

Worked Examples — Correct and Incorrect

The spec teaches the model through small programs; the instructive ones are the broken ones.

A correct handoff via a buffered channel — guaranteed to print hello, world:

var c = make(chan int, 10)
var a string
 
func f() {
	a = "hello, world" // (1) ordinary write
	c <- 0             // (2) send: synchronizing write-like
}
 
func main() {
	go f()
	<-c          // (3) receive: synchronizing read-like, observes (2)
	print(a)     // (4) ordinary read
}

The chain is: (1) sequenced-before (2) in f; (2) synchronized-before (3) because the receive observes the send; (3) sequenced-before (4) in main. Transitively, (1) happens-before (4), so (4) must observe hello, world.

Incorrect — busy-wait without synchronization. This program may print the empty string and may loop forever:

var a string
var done bool
 
func setup() {
	a = "hello, world"
	done = true
}
 
func main() {
	go setup()
	for !done {  // racy read of done — no synchronized-before edge
	}
	print(a)
}

There is no synchronizing operation between the two goroutines, so there is no happens-before between the write to done and the read of done. Two failures follow. First, even if main sees done == true, that does not imply it sees the write to a — there is no edge ordering them, so print(a) may show "". Second — and worse — “there is no guarantee that the write to done will ever be observed by main; the compiler, seeing no synchronization, may legally hoist done into a register, and “the loop in main is not guaranteed to finish.” The fix is a channel, a lock, or an atomic — anything that creates the edge.

Incorrect — double-checked locking. The same flaw in disguise:

var a string
var done bool
var once sync.Once
 
func doprint() {
	if !done {            // racy read
		once.Do(setup)
	}
	print(a)              // may print ""
}

once.Do does create an edge, but only between the Do calls — “there is no guarantee that, in doprint, observing the write to done implies observing the write to a.” Reading done outside any synchronization sidesteps the Once guarantee entirely. Remove the if !done check and just call once.Do(setup) unconditionally.

Incorrect — pointer publication race. Publishing a *T through an unsynchronized variable does not publish what it points to:

var g *T
func setup() { t := new(T); t.msg = "hello, world"; g = t }
func main()  { go setup(); for g == nil { }; print(g.msg) }

“Even if main observes g != nil and exits its loop, there is no guarantee that it will observe the initialized value for g.msg.” The store of g and the store of g.msg are unordered with respect to main’s racy reads. Use atomic.Pointer[T] to publish g, and the atomic rule supplies the edge.

The spec’s blunt advice closes the document: “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.”

Constraints the Model Places on the Compiler

The model is not only a promise to the programmer — it is a restriction on the compiler. Several optimizations that are legal for single-threaded C are forbidden in Go because they could introduce a race-visible behavior. The spec gives concrete prohibited rewrites. A compiler may not speculatively move a write out of a conditional (if cond { *p = 2 } must not become an unconditional *p = 2 then conditional restore — a racing reader could then see 2 when cond was false). It may not introduce a read ahead of a loop or a function call that might not return (the original program might never touch *p; the rewrite would). It may not reload a value from shared memory after first reading it into a local — “the value of *p may have changed”; it must spill the local to the stack instead. It may not use a shared location as scratch space (rewriting *p = i + *p/2 as two assignments exposes an intermediate value a racing reader could observe).

There is one escape hatch: a compiler may introduce a benign race if it can prove the race does not affect correctness on the target platform — e.g. hoisting a guaranteed-non-faulting *shared read out of a loop is permitted on essentially all CPUs, because the extra read cannot perturb any existing access. The spec notes pointedly that “all these optimizations are permitted in C/C++ compilers,” so a Go compiler sharing a backend with a C/C++ compiler “must take care to disable optimizations that are invalid for Go.”

Common Misunderstandings

atomic makes the whole struct safe.” An atomic operation synchronizes only the one variable it touches. Reading a flag atomically does not retroactively synchronize unrelated fields written before it — unless those writes are sequenced-before the atomic write in the same goroutine and the reader does an atomic read that observes it (then happens-before chains them). Use the atomic as the publication point and put the data writes before it.

“Volatile-like fields.” Go has no volatile keyword. The only way to get volatile-grade semantics is sync/atomic. A plain field read in a loop may be hoisted into a register forever.

“Channels are slow, so I’ll use a bare bool flag.” A bare flag is a data race with the consequences above. If you want a lightweight signal, use atomic.Bool — it is correct and fast.

“The race detector proves my code is correct.” [[Race Detector Internals|-race]] only reports races it actually observed on this run. A race on an untaken schedule is invisible. The detector is a powerful bug-finder, not a proof of data-race-freedom.

Alternatives and Comparison

The C/C++11 memory model offers a richer menu — relaxed, acquire, release, acq_rel, seq_cst atomics — and treats races as undefined behavior. It is more expressive and can be faster on weak hardware, at the cost of being far harder to use correctly and far less debuggable. The Java memory model also defines racy programs (no undefined behavior) but has historically struggled to formally rule out “out of thin air” values for relaxed accesses. Go’s choice is deliberately the simplest defensible point: one flavor of atomic (sequentially consistent), races defined-but-bounded, and a model small enough to state in a paragraph. The trade-off is that Go cannot express the very fastest lock-free algorithms that rely on relaxed atomics — but the Go team’s position is that almost no program should (Cox, Updating the Go Memory Model).

Production Notes

In practice, the memory model is invisible to most Go code because channels, sync.Mutex, and sync.WaitGroup already supply all the happens-before edges you need. The model becomes load-bearing exactly when a team reaches for lock-free tricks: a sync.Pool-style per-P cache, a lock-free ring buffer, a published pointer. The disciplined practice is: design with channels and locks first; if profiling forces a lock-free path, use sync/atomic for every shared access on that path and run the race detector continuously in CI. The 2022 formalization matters here precisely because it finally pinned down what sync/atomic means — before it, lock-free Go code was relying on folklore.

Uncertain

Verify: that the go.dev/ref/mem document is unchanged for Go 1.26 and still carries the June 6, 2022 date. Reason: the memory model is a stable spec and is not expected to change per-release, but this was not separately confirmed against a Go-1.26-tagged copy. To resolve: check the document header date and the Go 1.26 release notes for any “memory model” entry. uncertain

See Also