Chapter 8: Data Races and the Memory Model

Chapters 3 through 7 taught channel-based coordination—goroutines communicate by passing data through channels, and channel operations provide synchronization. When a pipeline stage sends a value downstream, ownership transfers with it. This discipline prevents conflicts: only one goroutine accesses data at a time.

But some problems naturally require many goroutines accessing the same data concurrently. Passing every counter increment or cache lookup through a channel would be heavyweight—the synchronization overhead exceeds the actual work.

Consider these scenarios

Where shared memory is the practical choice:

metrics.go
// Illustrative snippet — not a complete program
// HTTP request metrics—every handler increments counters
type Server struct {
    totalRequests int64
    errorCount    int64
}

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    s.totalRequests++  // Multiple handlers run concurrently
    // ...
}
cache.go
// Illustrative snippet — not a complete program
// Configuration cache—many readers, occasional writer
type Cache struct {
    config Config
}

func (c *Cache) Get() Config { return c.config }
func (c *Cache) Update(cfg Config) { c.config = cfg }
pool.go
// Illustrative snippet — not a complete program
// Worker pool statistics—multiple workers update shared counters
type Pool struct {
    jobsProcessed int
    jobsFailed    int
}
CHANNELS VS SHARED MEMORY

A two-part contrast. Above, channel communication from Part 2: goroutine A passes a value through a channel to goroutine B, with ownership transferring alongside the data, the runtime enforcing safety, and the result race-free by construction. Below, shared memory in Part 3: goroutines A and B both point at one shared variable, accessing it directly with no ownership transfer, so safety is the programmer's job and data races become possible without synchronization.

When goroutines share memory directly, a new class of bug becomes possible—one that doesn’t just produce wrong answers, but renders your program’s behavior undefined. This chapter teaches you to recognize these bugs and understand why they’re dangerous. Later chapters teach you to prevent them.

Recall the Four Questions from §2.1. Before creating any goroutine, you should answer:

  1. How does this goroutine exit?
  2. How does it communicate results?
  3. How are errors handled?
  4. What data does it access?

Question 4 is where data races live. If two goroutines access the same data and at least one writes, you have a potential data race. This chapter teaches you to recognize that situation and understand why it’s dangerous.

By the end of this chapter, you’ll understand
  • What constitutes a data race and why it causes undefined behavior
  • The difference between data races and race conditions
  • How to use Go’s race detector effectively
  • The Go memory model’s happens-before relationships
  • Strategies for preventing data races
What we’re NOT covering in Chapter 8
  • sync.Mutex mechanics and patterns—Chapter 9
  • sync.RWMutex and other sync package types—Chapter 9
  • Atomic operations—Chapter 11
  • Channel-based alternatives to shared memory—already covered in Chapters 3–7
Prerequisites

You should understand goroutine creation and the Four Questions from Chapter 2 (especially Q4: “What data does it access?”). You’ve mastered channel-based coordination in Chapters 3–7; this chapter teaches when and how to use shared memory instead.


8.1 What Is a Data Race?

A data race occurs when three conditions are all true simultaneously:

  1. Two or more goroutines access the same memory location
  2. At least one access is a write
  3. The accesses are not synchronized

If any condition is false, there’s no data race. Remove any one, and the race disappears.

THE THREE CONDITIONS FOR A DATA RACE

Three conditions bracketed together into one outcome: the same memory location, at least one write, and no synchronization together make a data race. Below, the same three read as an escape route — different memory locations means independent access, all reads with no writes is safe, and synchronized access is ordered. Removing any one condition removes the race.

Let’s examine each condition.


Condition 1: Same Memory Location

Two goroutines must access the same variable, struct field, slice element, or map entry:

counter.go
// Illustrative snippet — not a complete program
var counter int  // Shared variable

go func() {
    counter++  // Access 1: reads and writes counter
}()

go func() {
    counter++  // Access 2: reads and writes counter
}()

Both goroutines access counter—the same memory location.

What counts as “same location”:

Row
x and x
s.field and s.field
arr[0] and arr[0]
arr[0] and arr[1]
*p and *q where p == q
m[k] and m[k] (same key)
m[k1] and m[k2] (different keys)

Condition 2: At Least One Write

If all accesses are reads, there’s no race—reading doesn’t modify memory, so concurrent reads cannot conflict:

safe_reads.go
// Illustrative snippet — not a complete program
var config = Config{Timeout: 30}  // Initialized before goroutines start

// Safe: concurrent reads only
go func() { fmt.Println(config.Timeout) }()  // Read
go func() { fmt.Println(config.Timeout) }()  // Read

The danger arises when at least one goroutine writes:

unsafe_write.go
// Illustrative snippet — not a complete program
var config = Config{Timeout: 30}

// DATA RACE: one read, one write
go func() { fmt.Println(config.Timeout) }()  // Read
go func() { config.Timeout = 60 }()          // Write

What counts as a write:

Row
x = 5
x++, x--
x += 1
s = append(s, v)
m[k] = v
Reading x
READ VS WRITE SAFETY

Two scenarios. Concurrent reads are safe: memory holds 42, both goroutines read 42, no conflict. A read racing a write is not: memory moves from 42 through an indeterminate state to 99 while one goroutine reads and the other writes, so the reader may see the old value, the new one, or something partial.


Condition 3: No Synchronization

If accesses are ordered by synchronization primitives, there’s no race—even with concurrent goroutines and writes:

mutex_safe.go
// Illustrative snippet — not a complete program
var (
    counter int
    mu      sync.Mutex
)

// Safe: mutex synchronizes access
go func() {
    mu.Lock()
    counter++
    mu.Unlock()
}()

go func() {
    mu.Lock()
    counter++
    mu.Unlock()
}()

The mutex ensures accesses never overlap. At any moment, exactly one goroutine accesses counter.

Note

Production code typically uses defer mu.Unlock() immediately after Lock() to ensure unlock on all exit paths. We use explicit calls here for clarity.

Synchronization mechanisms that prevent races:

Synchronization Mechanisms
sync.Mutex
Exclusive access
sync.RWMutex
Multiple readers OR one writer
Channels
Send happens-before receive
sync/atomic
Lock-free atomic operations
sync.WaitGroup
Wait for completion
sync.Once
Single initialization

The Counter Example

Here’s the canonical data race:

counter.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    counter := 0
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Go(func() {
            counter++  // DATA RACE
        })
    }

    wg.Wait()
    fmt.Println("Counter:", counter)
}
Closure Capture

The counter variable is captured by the closure—a common pattern where races occur. Each goroutine shares access to the same local variable.

Expected: Counter: 1000

Actual (varies each run):

Terminal
$ go run counter.go
Counter: 951
$ go run counter.go
Counter: 965
$ go run counter.go
Counter: 960
$ go run counter.go
Counter: 967
Measured go1.26.1, darwin/amd64: four consecutive runs. Across 150 runs the value stayed in the 900s and never once reached 1000 — with a thousand unsynchronized increments, losing none of them is not a thing that happens. Your own numbers will sit somewhere else in that band, and lower again under -race, whose bookkeeping widens the window in which an update can be lost. A race that sometimes gives the right answer is real, but this is not the program that shows it.

The varying output is a symptom of the data race, but the varying output isn’t the actual problem. The problem is that the program’s behavior is undefined.

DETECTING THIS RACE

Run the counter example with the race detector:

Race Detector Output
$ go run -race counter.go
==================
WARNING: DATA RACE
Read at 0x00c000188038 by goroutine 8:
  main.main.func1()
      /path/to/counter.go:14 +0x2e
  sync.(*WaitGroup).Go.func1()
      /usr/local/go/src/sync/waitgroup.go:258 +0x5d
Previous write at 0x00c000188038 by goroutine 7:
  main.main.func1()
      /path/to/counter.go:14 +0x44
  sync.(*WaitGroup).Go.func1()
      /usr/local/go/src/sync/waitgroup.go:258 +0x5d
Goroutine 8 (running) created at:
  sync.(*WaitGroup).Go()
      /usr/local/go/src/sync/waitgroup.go:238 +0x86
  main.main()
      /path/to/counter.go:13 +0x7d
Goroutine 7 (running) created at:
  sync.(*WaitGroup).Go()
      /usr/local/go/src/sync/waitgroup.go:238 +0x86
  main.main()
      /path/to/counter.go:13 +0x7d
==================

The race detector immediately identifies the problem, showing exactly which goroutines accessed the variable and where in the code. We’ll cover the race detector in detail in §8.3.


Why counter++ Races

counter++ looks like one operation but compiles to three:

counter++ IS NOT ATOMIC

The single source line `counter++` expanded into its three machine steps: read the value from memory into a temporary, add one to the temporary, write the temporary back. The caption notes that another goroutine can act between any two of those steps.

When two goroutines execute counter++ concurrently, their operations can interleave:

LOST UPDATE: INTERLEAVED EXECUTION

A six-row timeline of two goroutines both incrementing a counter that starts at zero. A reads 0, B reads 0, A computes 1, B computes 1, A writes 1, B writes 1. The counter ends at 1 where 2 was expected, because both goroutines read the same starting value and both wrote the same result. One increment was lost, and with a thousand goroutines hundreds are.

This is called a lost update—one of many symptoms of data races. But lost updates are the benign case. Data races cause problems far worse than wrong counts.


What a Data Race Actually Costs You

A program with a data race does not merely produce “wrong answers sometimes.” But Go is more specific than “anything can happen,” and the specifics are worth knowing, because they tell you which races merely corrupt a number and which corrupt memory.

The Go memory model says three things about a program that races. First, an implementation is always allowed to detect the race, report it and halt — which is exactly what -race builds do. Second, for a value no larger than a machine word, a read must observe some value actually written by a preceding or concurrent write; acausal and “out of thin air” values are forbidden. Third — and this is where it gets dangerous — reads of anything larger than a word are only encouraged to behave that way. An implementation may treat them as several word-sized operations in an unspecified order.

WHAT A RACE CAN DO TO YOU

Possible outcomes include:

  • Wrong values (lost updates, stale reads)
  • Torn reads/writes (seeing partial multi-word updates)
  • Crashes (nil pointer panic, invalid memory access)
  • Corrupted data structures (inconsistent internal state)
  • Correct behavior (the most dangerous—hides the bug)

The last outcome is the most dangerous—it convinces you the code works.

The compiler and runtime assume your code is race-free and optimize accordingly—reordering operations, eliminating redundant memory accesses, caching values in registers. When that assumption is violated, these optimizations produce behavior that looks impossible from a reading of the source. The program is broken even when it appears to work.

GO IS NOT C: RACES HAVE A BOUNDED SET OF OUTCOMES

You will read, often, that a data race in Go is “undefined behavior—the program can do anything.” That is the C and C++ rule, and Go deliberately does not adopt it. The memory model puts it directly:

“These implementation constraints make Go more like Java or JavaScript, in that most races have a limited number of outcomes, and less like C and C++, where the meaning of any program with a race is entirely undefined, and the compiler may do anything at all.”

Race a single int and you will read some value that was really written — the wrong one, but a real one. Race a struct, an interface, a slice, a string or a map and you can observe a value that was never written at all, because the word-sized halves came from different updates. When those halves are a (pointer, length) or (pointer, type) pair, the result is arbitrary memory corruption.

That distinction is the reason the next two examples use a struct and an interface rather than a counter. It is also why “it’s only an int, it’ll be fine” is a bad argument rather than a wrong one: the failure is smaller, but the bug is identical and the race detector will still refuse to let it through.


Torn Reads: Seeing Impossible States

Consider a data race on a struct:

torn_read.go
// Illustrative snippet — not a complete program
type Config struct {
    Host string
    Port int
}

var config Config  // Shared, unsynchronized

// Goroutine A: updates config
func updateConfig(host string, port int) {
    config.Host = host
    config.Port = port
}

// Goroutine B: reads config
func connect() {
    addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
    dial(addr)
}

If updateConfig("newhost", 8080) runs while connect() reads, goroutine B might see:

The last two are torn reads—seeing a state that never existed as a complete, coherent value.

TORN READ: INCONSISTENT STATE

A four-row timeline of a torn read on a two-field struct. The writer sets Host to the new value, the reader then reads Host and gets the new value, the reader reads Port and gets the old one, and only afterwards does the writer set Port. The reader ends up with a Host and Port combination that was never simultaneously true, and dials the wrong server.

MULTI-WORD VALUES ARE ESPECIALLY DANGEROUS

Strings, slices, interfaces, and maps are all multi-word values internally:

Internal Structure of Multi-Word Types
string
pointer + length (2 words)
[]T (slice)
pointer + length + capacity (3 words)
interface{}
type pointer + data pointer (2 words)
map[K]V
pointer to hash table structure

A data race on any of these can produce torn reads where the components are inconsistent with each other—reading a string’s new pointer with its old length, for example.


Pointer and Interface Races: Crashes

Data races on pointers or interfaces can cause crashes:

interface_race.go
// Illustrative snippet — not a complete program
var handler http.Handler  // Shared, unsynchronized

// Goroutine A: replaces handler
func updateHandler(h http.Handler) {
    handler = h
}

// Goroutine B: uses handler
func handleRequest(w http.ResponseWriter, r *http.Request) {
    handler.ServeHTTP(w, r)  // May crash
}

In Go, an interface value is two words: a type pointer and a data pointer. Without synchronization, a reader might see a torn value:

INTERFACE TORN READ

An interface value shown as its two words: a type pointer and a data pointer. A writer replaces both, moving from type A with data A to type B with data B. A reader can observe the halves from different updates — type B paired with data A. Calling a method then dispatches through type B's method table against data A's memory, and the program crashes.


The Race You Can’t See: Reordering

The most dangerous races are the ones that don’t manifest during development:

reorder.go
// Illustrative snippet — not a complete program
var ready bool
var data int

func setup() {
    data = 42      // Write 1
    ready = true   // Write 2
}

func use() {
    if ready {
        fmt.Println(data)  // DATA RACE: may print 0!
    }
}

// Called from separate goroutines:
go setup()
go use()

This looks reasonable: set data, then set ready. But without synchronization:

  1. CPU reordering: The CPU may execute or make visible the writes in different order
  2. Cache effects: Another CPU core may see ready = true before seeing the updated data
  3. Compiler optimization: Independent writes may be reordered for efficiency
REORDERING MAKES RACES INVISIBLE

Two columns. On the left, the order you wrote: assign data, then set ready. On the right, the order another core may observe: ready set first, data second. The explanation is that the compiler and CPU reorder independent operations for pipelining, cache and register reasons, and from their point of view these two writes have no dependency. A reader can therefore see ready true while data is still zero.

This race might never trigger on your development machine but fail consistently on a different CPU architecture or under production load. This is why race bugs discovered in production are often impossible to reproduce locally—the reordering that exposes the race only happens under specific CPU, compiler, or load conditions.


Test Your Understanding

Example 1: Shared counter

ex81_1.go
// Illustrative snippet — not a complete program
var count int
for i := 0; i < 100; i++ {
    go func() { count++ }()
}

Race? Yes—multiple goroutines write count without synchronization.

Example 2: Goroutine-local variables

ex81_2.go
// Illustrative snippet — not a complete program
for i := 0; i < 10; i++ {
    go func() {
        localCount := 0
        localCount++
        fmt.Println(i, localCount)
    }()
}

Race? No—each goroutine has its own localCount. No shared memory.

Example 3: Read-only shared data

ex81_3.go
// Illustrative snippet — not a complete program
var config = loadConfig()  // Package level; never modified after

go func() { fmt.Println(config.Timeout) }()  // Read
go func() { fmt.Println(config.Timeout) }()  // Read

Race? No—config is initialized before goroutines start (package-level initialization), and only read afterward. No writes after initialization means no race.

Example 4: Channel communication

ex81_4.go
// Illustrative snippet — not a complete program
ch := make(chan int)
go func() { ch <- 42 }()
go func() { fmt.Println(<-ch) }()

Race? No—channel operations are synchronized by the runtime.

Example 5: Mutex-protected counter

example5.go
// Illustrative snippet — not a complete program
var mu sync.Mutex
var count int

go func() { mu.Lock(); count++; mu.Unlock() }()
go func() { mu.Lock(); count++; mu.Unlock() }()

Race? No—mutex ensures only one goroutine accesses count at a time.

Example 6: Map with concurrent writes

example6.go
// Illustrative snippet — not a complete program
var cache = make(map[string]string)

for i := 0; i < 100; i++ {
    go func() { cache[fmt.Sprint(i)] = "value" }()
}

Race? Yes—this is a data race. Go’s runtime usually detects concurrent map writes and panics with fatal error: concurrent map writes, but detection is not guaranteed.

MAPS ARE NOT THREAD-SAFE

Go’s built-in maps have no internal locking:

  • Concurrent writes: Runtime detects and panics with fatal error: concurrent map writes
  • Concurrent read + write: Undefined—may corrupt silently, panic, or appear to work

Don’t rely on the panic as a safety mechanism—it’s a symptom, not protection. Always synchronize map access using sync.Mutex, or use sync.Map (Chapter 12) for concurrent access patterns.


The Four Questions Connection

Chapter 2 introduced the Four Questions for every goroutine. Question 4—“What data does it access?”—directly addresses race prevention:

Row
Q1: How does it exit?
Q2: How does it communicate?
Q3: How are errors handled?
Q4: What data does it access?

When you answer Q4 and find that multiple goroutines access the same data with at least one write, you’ve identified a potential data race. The follow-up question: “How is that access synchronized?”


Common Mistakes

Row
“It’s just a boolean”
x++ is one operation”
“I only read it once”
“It works in my tests”
“I sleep long enough”
THERE’S HELP

Go provides a built-in race detector that finds these bugs automatically. §8.3 covers go run -race and go test -race—essential tools for any concurrent Go development.


Key Takeaways

  1. Three conditions define a race—same memory, at least one write, no synchronization. Remove any one to eliminate the race.
  2. A race is worse than a wrong number—a word-sized value reads as some real write, but structs, interfaces, slices, strings and maps can tear into values that were never written, and a torn (pointer, length) pair corrupts memory
  3. counter++ is not atomic—it’s read-modify-write (three operations that can interleave)
  4. Multi-word values tear—strings, slices, interfaces, and maps can show inconsistent internal state
  5. Reordering is invisible—compiler and CPU may reorder operations, breaking assumptions that only exist in your mind
  6. Maps always need synchronization—concurrent writes usually panic, but read+write may corrupt silently
  7. Use the race detector—“works on my machine” proves nothing; go run -race catches races that testing misses (§8.3)

Next: §8.2 distinguishes data races from race conditions—a crucial distinction that even experienced developers often confuse.

8.2 Data Races vs Race Conditions

§8.1 defined data races: concurrent memory access with at least one write and no synchronization. But there’s another term you’ll encounter—race condition—that sounds similar but describes a fundamentally different problem.

These terms are often used interchangeably. This is a mistake. They have different causes, different consequences, and different solutions.

DATA RACE VS RACE CONDITION

A side-by-side comparison. A data race is a memory-safety violation caused by unsynchronized concurrent access with at least one write; it produces memory-unsafe results, is found by the race detector, and is fixed with synchronization. A race condition is a logic bug where correctness depends on the timing of operations; it produces wrong but well-defined behavior, is found by testing and code review, and is fixed with a correct algorithm. The closing point: you can have either without the other, and adding mutexes removes data races without fixing race conditions.

Understanding this distinction matters because:


Definitions

Data race: Two goroutines access the same memory location concurrently, at least one is a write, and there’s no synchronization. The value you read is not predictable, and for anything wider than a machine word it may be a value that was never written — see §8.1.

Race condition: Program correctness depends on the relative timing or ordering of operations. The outcome varies based on which operation “wins the race.” This produces wrong but defined behavior—the program does something predictable, just not what you wanted.


Race Condition Without Data Race

The most important insight: you can have race conditions in perfectly synchronized code.

Consider a bank account with properly synchronized methods:

account.go
// Illustrative snippet — not a complete program
type Account struct {
    mu      sync.Mutex
    balance int
}

func (a *Account) Balance() int {
    a.mu.Lock()
    defer a.mu.Unlock()
    return a.balance
}

func (a *Account) Withdraw(amount int) bool {
    a.mu.Lock()
    defer a.mu.Unlock()
    if a.balance >= amount {
        a.balance -= amount
        return true
    }
    return false
}

func (a *Account) Deposit(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.balance += amount
}

Each method is properly synchronized. There are no data races—the mutex ensures exclusive access to balance. The race detector will report nothing.

But this code has a race condition when used like this:

transfer_unsafe.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Check-then-act with gap
func transferUnsafe(from, to *Account, amount int) bool {
    if from.Balance() >= amount {  // Check
        from.Withdraw(amount)       // Act (return value ignored!)
        to.Deposit(amount)
        return true
    }
    return false
}

The bug: Between checking Balance() and calling Withdraw(), another goroutine can withdraw funds:

RACE CONDITION: CHECK-THEN-ACT

A two-goroutine timeline of a check-then-act bug in a money transfer, starting with 100 dollars in one account and zero in the other. Both goroutines check the balance and both see 100, so both decide 80 is affordable. A's withdrawal succeeds and leaves 20; B's withdrawal fails against the reduced balance. But both go on to deposit 80, so the destination ends at 160. There is no data race — every method is synchronized — but the transfer ignored the withdrawal's return value, and money was created out of nothing.

The race detector won’t catch this because there’s no data race—every memory access is properly synchronized. But the program is still wrong: the check in transferUnsafe became stale before the action occurred.

CHECK-THEN-ACT IS THE CLASSIC RACE CONDITION

This pattern appears everywhere:

check_then_act.go
// Illustrative snippet — not a complete program
if condition() {    // Check
    act()           // Act—condition may have changed!
}

Examples:

Rule: Check-then-act must be atomic. The lock must cover both operations.

Fixing the race condition:

transfer_safe.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Check and act atomically under same lock
func (a *Account) TransferTo(to *Account, amount int) bool {
    if a == to {
        return true  // Self-transfer is a no-op
    }

    a.mu.Lock()
    defer a.mu.Unlock()

    if a.balance < amount {
        return false
    }

    a.balance -= amount

    to.mu.Lock()
    to.balance += amount
    to.mu.Unlock()

    return true
}

Now the balance check and withdrawal happen while holding the lock—no other goroutine can see or modify the balance in between.

WARNING: THIS EXAMPLE HAS DEADLOCK POTENTIAL

The code above demonstrates atomic check-and-act. The self-transfer guard prevents the self-deadlock, but one issue remains:

  1. Cross-transfer: If goroutine 1 calls a.TransferTo(b, x) while goroutine 2 calls b.TransferTo(a, y), they acquire locks in opposite order—potential deadlock

Production code requires consistent lock ordering. Chapter 10 covers deadlock prevention strategies in depth, including lock ordering and deadlock detection.

THE FIX ADDRESSES BOTH BUGS

The original transferUnsafe had two bugs:

  1. Check-then-act gap: Balance could change between check and withdrawal
  2. Ignored return value: Proceeded with deposit even if Withdraw() returned false

The corrected TransferTo addresses both: the check and balance modification happen atomically under the same lock, and we return false immediately if the check fails—no separate Withdraw call whose return value could be ignored.


Adding Mutex Alone Isn’t Enough

A common mistake: adding a mutex but releasing it between check and act.

mutex_gap.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Mutex but gap between check and act
func increment() {
    mu.Lock()
    belowLimit := counter < 100
    mu.Unlock()  // Released here!

    if belowLimit {
        mu.Lock()
        counter++  // Another goroutine may have incremented!
        mu.Unlock()
    }
}

// ✓ CORRECT: Check and act under same lock
func increment() {
    mu.Lock()
    defer mu.Unlock()

    if counter < 100 {
        counter++
    }
}

The first version has no data race—every access is protected. But it has a race condition because belowLimit can become stale before we act on it.


Why “Benign” Data Races Aren’t Benign

Can you have a data race where the program logic appears correct? Some developers call these “benign races”—though as we’ll see, they’re anything but benign.

benign_race.go
// Illustrative snippet — not a complete program
var done bool  // Unsynchronized

func worker() {
    processData()
    done = true  // DATA RACE: write without synchronization
}

func coordinator() {
    go worker()

    for !done {  // DATA RACE: read without synchronization
        time.Sleep(time.Millisecond)
    }
    fmt.Println("Complete")
}

Data race? Yes—concurrent read and write to done without synchronization.

Race condition? Also yes—due to caching and visibility rules, the coordinator may spin forever, never observing the write. The program’s correctness depends on timing and hardware memory behavior.

Why this specific race breaks:

WHY “BENIGN RACES” AREN’T BENIGN

Three scenarios showing why a race on a boolean flag is not harmless. First, register caching: the compiler may load the flag once into a register and spin on the register, never seeing the memory write, so the loop never exits. Second, reordering: a worker may make the done flag visible before the work it announces, so the coordinator proceeds against unprocessed data. Third, cache visibility: on a multi-core machine one core's write may not reach another for an unbounded time without synchronization. The caption notes these happen in production, not just in theory.

THERE ARE NO “BENIGN” DATA RACES IN GO

The term “benign race” should be avoided entirely. What appears benign today becomes malicious tomorrow when:

  • A compiler update introduces new optimizations
  • Code runs on a different CPU architecture (ARM, RISC-V)
  • Load patterns shift, exposing race windows that were too narrow before

The Go memory model makes no guarantees about racy code. Rule: Fix all data races. If the race detector reports it, fix it.

Memory model perspective: Without synchronization, there’s no happens-before relationship (§8.4) between the write and read. The memory model makes no guarantees about visibility—the read may never observe the write.

Correct implementation:

benign_race_fixed.go
// Illustrative snippet — not a complete program
var done atomic.Bool

func worker() {
    processData()
    done.Store(true)
}

func coordinator() {
    go worker()

    for !done.Load() {
        time.Sleep(time.Millisecond)
    }
    fmt.Println("Complete")
}

Atomic operations establish happens-before relationships (§ 8.4), so when Load() returns true, the coordinator is guaranteed to see processData()’s side effects. This fixes both the caching and the reordering problems—the compiler cannot move Store(true) before processData().


Both Data Race AND Race Condition

Many real-world bugs combine both problems:

both_bugs.go
// Illustrative snippet — not a complete program
var counter int  // Unsynchronized

func increment() {
    if counter < 100 {  // DATA RACE: unsynchronized read
        counter++       // DATA RACE: unsynchronized read-modify-write
    }
}

// Multiple goroutines
for i := 0; i < 200; i++ {
    go increment()
}

Data race: Multiple goroutines access counter without synchronization. Undefined behavior.

Race condition: Even with synchronization, the check-then-act pattern would still be buggy if the lock is released between check and act.

Both bugs must be fixed:

both_bugs_fixed.go
// Illustrative snippet — not a complete program
var (
    counter int
    mu      sync.Mutex
)

func increment() {
    mu.Lock()
    defer mu.Unlock()

    // Check and act atomically under same lock
    if counter < 100 {
        counter++
    }
}

Now there’s no data race (mutex protects access) and no race condition (check-then-act is atomic).


The Four Combinations

DATA RACE × RACE CONDITION MATRIX

A two-by-two matrix of data race against race condition. No race and no race condition is correct code, the goal. No data race but a race condition is a subtle logic bug the race detector will not catch. A data race with correct logic gives memory-unsafe results and the detector will catch it. Both together is the worst case and common in buggy code.


TOCTOU: Time-of-Check to Time-of-Use

TOCTOU is the formal name for check-then-act race conditions, particularly common in file operations. The vulnerability exists in the gap between checking a condition and using the result.

toctou.go
// Illustrative snippet — not a complete program
// ✗ TOCTOU: File may change between check and use
func processFile(path string) error {
    info, err := os.Stat(path)
    if err != nil {
        return err
    }
    if info.Size() > maxSize {
        return errors.New("file too large")
    }

    // Gap: file can change here!

    data, err := os.ReadFile(path)  // May read different content
    if err != nil {
        return err
    }
    return process(data)
}

Between Stat and ReadFile:

Not a data race (no shared memory between goroutines), but still a race condition—the check becomes stale before use. Race conditions aren’t limited to goroutines; they can occur anywhere timing matters.

Fix: Act atomically, handle errors

toctou_fixed.go
// Illustrative snippet — not a complete program
// ✓ No TOCTOU: Open once, use the handle
func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    info, err := f.Stat()  // Stat the open file, not the path
    if err != nil {
        return err
    }
    if info.Size() > maxSize {
        return errors.New("file too large")
    }

    data, err := io.ReadAll(f)  // Read from same handle
    if err != nil {
        return err
    }
    return process(data)
}

For file creation, use atomic flags:

atomic_create.go
// Illustrative snippet — not a complete program
// ✗ TOCTOU: File may be created between check and create
if _, err := os.Stat(path); os.IsNotExist(err) {
    os.Create(path)  // Another process might create it first
}

// ✓ Atomic: O_EXCL fails if file exists
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
“ASK FORGIVENESS, NOT PERMISSION”

In concurrent systems, checking before acting creates race windows. Instead:

  • Don’t: Check if file exists, then create it
  • Do: Try to create exclusively, handle “already exists” error
  • Don’t: Check if key in map, then insert
  • Do: Lock around both check and insert as a single operation

Combine check and act into a single atomic operation when possible.


Test Your Understanding

Example 1: Atomic counter with check

atomic_check.go
// Illustrative snippet — not a complete program
var counter atomic.Int64

func incrementIfBelow(max int64) bool {
    current := counter.Load()
    if current < max {
        counter.Add(1)
        return true
    }
    return false
}

Data race? No—atomic operations are synchronized.

Race condition? Yes—another goroutine might increment between Load() and Add(), causing counter to exceed max.

FIX PREVIEW

Use compare-and-swap (CAS) in a loop to atomically check and increment—covered in Chapter 11.

Example 2: Map with mutex

map_mutex.go
// Illustrative snippet — not a complete program
var (
    cache = make(map[string]string)
    mu    sync.Mutex
)

// No defer — intentionally release lock before expensive computation
func getOrCompute(key string) string {
    mu.Lock()
    if val, ok := cache[key]; ok {
        mu.Unlock()
        return val
    }
    mu.Unlock()

    // Expensive; lock released so other callers are not blocked
    computed := expensiveCompute(key)

    mu.Lock()
    cache[key] = computed
    mu.Unlock()
    return computed
}

Data race? No—mutex protects all map access.

Race condition? Yes—two goroutines may both compute for the same key if they both see “not found” before either stores.

IMPACT

This is a performance bug, not a correctness bug—both goroutines compute the same value, wasting resources. For expensive operations, use singleflight pattern or per-key synchronization.

Example 3: Channel select

channel_select.go
// Illustrative snippet — not a complete program
func queryFirst(ch1, ch2 <-chan int) int {
    select {
    case v := <-ch1:
        return v
    case v := <-ch2:
        return v
    }
}

Data race? No—channels are synchronized by runtime.

Race condition? Depends on intent. If either result is acceptable (first-response-wins pattern), this is correct by design. If a specific channel’s result is required, this is a bug.


Common Mistakes

“I added a mutex, so it’s safe”
Problem

Mutex prevents data races, not race conditions

Fix

Ensure check-then-act is atomic

“Race detector passed”
Problem

Detector finds data races, not race conditions

Fix

Manual analysis for logic races

“Each operation is atomic”
Problem

Sequences of atomic operations aren’t atomic

Fix

Make the entire sequence atomic

“Channels can’t race”
Problem

Channels prevent data races, not race conditions

Fix

Logic can still depend on timing

Confusing the two terms
Problem

Different problems, different solutions

Fix

Data race = memory; Race condition = logic


Summary

Data Race vs Race Condition Comparison
Row
Definition
Level
Consequence
Detection
Severity
Fix

Key Takeaways

  1. Data races and race conditions are different problems—data races cause undefined behavior; race conditions cause logic bugs. The race detector finds only data races.
  2. Synchronized code can still have race conditions—adding mutexes prevents data races but doesn’t fix algorithmic bugs
  3. Check-then-act must be atomic—hold the lock across both the check and the action; the gap between them is the vulnerability
  4. There are no “benign” data races—compiler optimizations, CPU reordering, and cache visibility can break “harmless” races
  5. TOCTOU bugs survive synchronization—each individual operation can be synchronized yet the sequence still races
  6. Act atomically, handle errors—don’t check then act; combine them into a single atomic operation when possible
  7. Passing -race is necessary but not sufficient—correct algorithm design is still required

Next: §8.3 covers Go’s race detector in depth—how to use it, interpret its output, and integrate it into your development workflow.

8.3 The Race Detector

§§8.1 and 8.2 taught you what data races are and how they differ from race conditions. But recognizing data races in code is hard—they’re timing-dependent, often invisible during development, and can lurk undetected for months. You need a tool.

Go provides one: the race detector. It’s built into the toolchain, requires no setup, and finds data races that would take humans hours to identify.

THE RACE DETECTOR

A summary card for the race detector. It is a dynamic analysis tool built into the toolchain that finds races at runtime, invoked with the -race flag on run, test or build. It detects concurrent unsynchronized access and therefore data races. It does not detect race conditions, deadlocks, goroutine leaks, or races on code paths that the run never executed. The cost is roughly 2 to 20 times slower and 5 to 10 times more memory: worth it in testing, not in production.

THE RACE DETECTOR ONLY FINDS EXECUTED RACES

The detector reports races that actually occur during your test run. Code paths not exercised won’t be checked. A race that doesn’t execute during testing won’t be detected, even if it exists in the code. This is why you need good test coverage and should run with -race in CI on realistic workloads.


Basic Usage

Enable the race detector with the -race flag:

Terminal
# Run with race detection
$ go run -race main.go
# Test with race detection (most common)
$ go test -race ./...
# Build a race-enabled binary
$ go build -race -o myapp
# Install with race detection
$ go install -race

The detector instruments your code at compile time, tracking all memory accesses and synchronization operations. When it detects concurrent access to the same memory location with at least one write and no synchronization, it reports a race.

MAKE -race STANDARD IN DEVELOPMENT

Running with -race during development catches bugs immediately, when the context is fresh.

Terminal
# Add to your shell aliases
$ alias gotest='go test -race'
$ alias gorun='go run -race'

Your First Race Report

Let’s trigger and examine a race report:

main.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    counter := 0
    var wg sync.WaitGroup

    for i := 0; i < 2; i++ {
        wg.Go(func() {
            counter++  // DATA RACE
        })
    }

    wg.Wait()
    fmt.Println("Counter:", counter)
}

Run with -race:

Race Detector Output
$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 7:
  main.main.func1()
      /path/to/main.go:15 +0x3a
Previous write at 0x00c0000b4010 by goroutine 6:
  main.main.func1()
      /path/to/main.go:15 +0x52
Goroutine 7 (running) created at:
  main.main()
      /path/to/main.go:12 +0x7a
Goroutine 6 (finished) created at:
  main.main()
      /path/to/main.go:12 +0x7a
==================
Counter: 2
Found 1 data race(s)
exit status 66
WHY “READ” AND “WRITE” FOR THE SAME LINE?

counter++ is actually three operations: read the current value, add 1, write the result back. The race detector caught goroutine 7’s read overlapping with goroutine 6’s write. If you run again, you might see “Write” and “Write” instead—it depends on which conflicting accesses the detector observed.


Anatomy of a Race Report

Every race report has the same structure:

RACE REPORT ANATOMY

An annotated race report. The first boxed section is the current access that triggered the report, showing the memory address, the goroutine id and a stack trace. The second is the conflicting previous access, at the same address from a different goroutine, at least one of the two being a write. The third gives the goroutine's creation site, which is what lets you trace the bug back to where it was spawned. The final line is the exit status printed by go run.

Key information in each report:

Race Report Fields
Memory address
Which variable is racing (same address = same location)
Access type
Read, Write, or both (e.g., counter++ is read-modify-write)
Stack traces
Exact file and line numbers for both accesses
Goroutine IDs
Which goroutines are involved
Creation points
Where each goroutine was spawned with go
“PREVIOUS” DOESN’T MEAN “CHRONOLOGICALLY FIRST”

The “previous” access is simply one that the detector tracked and observed conflicting with the “current” access. Both accesses race with each other—neither is more “at fault.” If you run again, the roles might reverse.

WHICH COMMAND EXITS WITH WHAT

66 is the race detector’s own exit code, and only a binary you built with -race and ran directly reports it to your shell. go run and go test both wrap that binary and exit 1, the way they do for any failure.

For CI this is a distinction without a difference — any non-zero status fails the step. It matters only if you planned to key on 66 specifically, which would silently never match:

ci.yaml
// Illustrative snippet — not a complete program
- name: Test with race detector
  run: go test -race ./...
  # go test exits 1 on a detected race, which fails the step.
  # Do not match on 66 here: that is the inner binary's code.

Reading Complex Reports

Real-world races often involve deeper call stacks:

cache.go
// Illustrative snippet — not a complete program
type Cache struct {
    data map[string]string
}

func (c *Cache) Get(key string) string {
    return c.data[key]  // Line 8
}

func (c *Cache) Set(key, value string) {
    c.data[key] = value  // Line 12
}

func main() {
    cache := &Cache{data: make(map[string]string)}
    var wg sync.WaitGroup

    wg.Go(func() {
        cache.Set("foo", "bar")  // Line 22
    })
    wg.Go(func() {
        _ = cache.Get("foo")  // Line 27
    })
    wg.Wait()
}
Race Detector Output
WARNING: DATA RACE
Write at 0x00c0000a0180 by goroutine 6:
  runtime.mapassign_faststr()
      /usr/local/go/src/internal/runtime/maps/runtime_faststr.go:263 +0x0
  main.(*Cache).Set()
      /path/to/main.go:12 +0x44
  main.main.func1()
      /path/to/main.go:22 +0x64
Previous read at 0x00c0000a0180 by goroutine 7:
  runtime.mapaccess1_faststr()
      /usr/local/go/src/internal/runtime/maps/runtime_faststr.go:101 +0x0
  main.(*Cache).Get()
      /path/to/main.go:8 +0x34
  main.main.func2()
      /path/to/main.go:27 +0x44

Those first frames are inside the runtime’s map implementation. Since Go 1.24 that lives in internal/runtime/maps/ (the Swiss-table rewrite); on an older toolchain the same frames point at runtime/map_faststr.go. Either way they are noise — the frames you want are the two below them, in your own package.

Reading strategy:

  1. Skip runtime internals. Ignore runtime.mapassign_faststr—that’s the map implementation.
  2. Find your code. Look for your package name: main.(*Cache).Set() on line 12.
  3. Identify the conflict. Set() writes to the map, Get() reads from it—both without synchronization.
  4. Trace goroutine creation. Lines 22 and 27 show where the goroutines were spawned.

Common runtime functions in reports:

Runtime Functions in Race Reports
runtime.mapassign*
Map write (m[k] = v)
runtime.mapaccess*
Map read (v := m[k])
runtime.growslice
Slice append/grow
runtime.typedmemmove
Struct assignment

These are normal—look at the line above them in your code for the actual source.


Multiple Races in One Run

The detector reports each distinct race it finds:

multi_race.go
// Illustrative snippet — not a complete program
var (
    counter1 int
    counter2 int
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Go(func() {
            counter1++  // Race 1
            counter2++  // Race 2
        })
    }
    wg.Wait()
}
Race Detector Output
Found 2 data race(s)
exit status 66
FIX RACES ITERATIVELY

When you see multiple race reports:

  1. Fix the first one
  2. Re-run with -race
  3. See if other races remain or new ones appear

One fix might eliminate others (same root cause) or expose new ones (changed timing). Don’t try to fix all races simultaneously.


What the Race Detector Catches

The detector finds races on all Go memory types:

detected_races.go
// Illustrative snippet — not a complete program
// ✓ DETECTED: Variables
var count int
go func() { count++ }()
go func() { count++ }()

// ✓ DETECTED: Struct fields
type Data struct{ value int }
var d Data
go func() { d.value = 1 }()
go func() { fmt.Println(d.value) }()

// ✓ DETECTED: Map access
var m = make(map[string]int)
go func() { m["a"] = 1 }()
go func() { _ = m["a"] }()

// ✓ DETECTED: Slice modification
var s []int
go func() { s = append(s, 1) }()
go func() { fmt.Println(len(s)) }()
THE DETECTOR UNDERSTANDS SYNCHRONIZATION

The race detector recognizes Go’s synchronization primitives: sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once, channels, and sync/atomic operations. Properly synchronized code won’t trigger false positives.

TRUST THE DETECTOR

The race detector has essentially no false positives. If it reports a race, you have a race—always fix it. The only question is whether that code path executes in production.


What the Race Detector Misses

The detector has fundamental limitations. Understanding them prevents false confidence.

1. Races in Unexecuted Code Paths

The detector only finds races that actually occur during execution:

unexecuted.go
// Illustrative snippet — not a complete program
// Given: var cache map[string]string; var key, value string
func process(useCache bool) {
    if useCache {
        // Race here—but only if useCache is true
        go func() { cache[key] = value }()
        _ = cache[key]
    }
}

func TestProcess(t *testing.T) {
    process(false)  // Race never triggered—not detected!
}
FALSE NEGATIVES ARE POSSIBLE

The race detector has:

  • No false positives: Every reported race is real—always fix them
  • False negatives: Races in unexecuted code paths are invisible
Interpreting Race Detector Results
Race reported
Definitely fix—confirmed data race
No race reported
Maybe OK—or race in untested path

The only way to reduce false negatives is to increase test coverage of concurrent code paths and test with production-like workloads.

2. Race Conditions (Logic Bugs)

From §8.2—synchronized code with timing-dependent correctness:

logic_race.go
// Illustrative snippet — not a complete program
// Given: var mu sync.Mutex; var balance int

// ✗ NOT DETECTED: Race condition with proper synchronization
func withdraw(amount int) bool {
    mu.Lock()
    hasEnough := balance >= amount
    mu.Unlock()  // Gap between check and act!

    if hasEnough {
        mu.Lock()
        balance -= amount
        mu.Unlock()
        return true
    }
    return false
}

The race detector sees properly synchronized memory access. It can’t detect that the logic is wrong.

3. Deadlocks and Goroutine Leaks

not_detected.go
// Illustrative snippet — not a complete program
// ✗ NOT DETECTED: Deadlock
var mu1, mu2 sync.Mutex
go func() { mu1.Lock(); mu2.Lock() }()
go func() { mu2.Lock(); mu1.Lock() }()

// ✗ NOT DETECTED: Goroutine leak
ch := make(chan int)
go func() { ch <- 1 }()  // Blocks forever, no receiver

Performance Overhead

Race detection isn’t free:

Race Detection Overhead
Execution time
2–20× slower (typically ~10×)
Memory usage
5–10× more
Binary size
~1.3× larger

This means:

Measured go1.26.1, darwin/amd64, 1.6 M mutex-protected increments across 8 goroutines: 62 ms → 685 ms, an 11.0× slowdown, and the binary went from 2.55 MB to 3.32 MB (1.3×). The memory multiplier is the one that will not reproduce on a toy program: shadow memory is allocated per word the program actually touches, so a workload with a small live heap pays almost nothing, and a large one pays the full 5–10×.
AVOID RACE-ENABLED BINARIES IN PRODUCTION

The 2–20× overhead makes race-enabled binaries unsuitable for production traffic. When go test -race detects a race it fails the run and exits 1; 66 is the code of the race-enabled binary underneath. Standalone binaries built with go build -race log race reports to stderr but continue running by default (configurable via GORACE="halt_on_error=1"). If you need runtime detection, consider running race-enabled builds in staging or canary environments where the overhead is acceptable.


CI/CD Integration

Make the race detector part of your continuous integration pipeline:

github-actions.yaml
// Illustrative snippet — not a complete program
# GitHub Actions
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: 'stable'  # Or pin to your project's version

      - name: Test with race detector
        run: go test -race -timeout 10m ./...
gitlab-ci.yaml
// Illustrative snippet — not a complete program
# GitLab CI
test:
  image: golang:latest  # Or pin to your project's version
  script:
    - go test -race -timeout 10m ./...

Best practices for CI:

Row
Run -race on every PR
Increase timeout (2–5×)
Fail the build on any race
Test with various GOMAXPROCS
Terminal
# Test with various concurrency levels to expose different race patterns
$ GOMAXPROCS=1 go test -race ./... # Serialized—different timing
$ GOMAXPROCS=4 go test -race ./... # Moderate parallelism
$ GOMAXPROCS=16 go test -race ./... # High contention scenarios

Environment Variables

Control race detector behavior with the GORACE environment variable:

Terminal
# Exit immediately on first race (default: report all then exit)
$ GORACE="halt_on_error=1" go test -race ./...
# Log to file instead of stderr
$ GORACE="log_path=/tmp/race.log" go test -race ./...
# Increase history for complex races
$ GORACE="history_size=4" go test -race ./...
# Combine options
$ GORACE="halt_on_error=1 log_path=race.log" go test -race ./...
Row
halt_on_error
log_path
history_size
atexit_sleep_ms
WHEN TO USE halt_on_error=1

Use halt_on_error=1 when debugging a specific race—it stops immediately, making it easier to examine state. Use the default (report all) in CI to see the full scope of race problems in one run.


Strategies for Better Detection

Since the detector only finds races in executed code, maximize coverage:

1. Write Concurrent Tests

Tests that only use one goroutine won’t detect races:

cache_test.go
// Illustrative snippet — not a complete program
// ✗ BAD: No concurrency, won't detect races
func TestCache(t *testing.T) {
    c := NewCache()
    c.Set("key", "value")
    _ = c.Get("key")
}

// ✓ GOOD: Concurrent access exposes races
func TestCacheConcurrent(t *testing.T) {
    c := NewCache()
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Go(func() {
            for j := 0; j < 100; j++ {
                c.Set(fmt.Sprintf("key-%d", j), "value")
            }
        })
        wg.Go(func() {
            for j := 0; j < 100; j++ {
                _ = c.Get(fmt.Sprintf("key-%d", j))
            }
        })
    }
    wg.Wait()
}

2. Run Tests Multiple Times

Races are non-deterministic. Running tests multiple times increases detection probability:

Terminal
# Run tests 100 times
$ go test -race -count=100 ./...
# Or stop on first failure
$ for i in {1..100}; do go test -race ./... || exit 1; done

3. Stress Test Concurrent Code

stress_test.go
// Illustrative snippet — not a complete program
func TestCacheStress(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping stress test in short mode")
    }

    c := NewCache()
    var wg sync.WaitGroup

    // 100 concurrent workers, each doing 1000 operations
    for i := 0; i < 100; i++ {
        wg.Go(func() {
            for j := 0; j < 1000; j++ {
                c.Set(fmt.Sprint(i), fmt.Sprint(j))
            }
        })
        wg.Go(func() {
            for j := 0; j < 1000; j++ {
                _ = c.Get(fmt.Sprint(i))
            }
        })
    }
    wg.Wait()
}

Common Patterns and Fixes

Pattern 1: Shared Counter

fix_counter.go
// Illustrative snippet — not a complete program
// ✗ RACE
var count int
go func() { count++ }()

// ✓ FIX: Use atomic
var count atomic.Int64
go func() { count.Add(1) }()

Pattern 2: Shared Map

fix_map.go
// Illustrative snippet — not a complete program
// ✗ RACE: concurrent write + read on map (undefined behavior)
var cache = make(map[string]string)
go func() { cache["a"] = "1" }()
go func() { _ = cache["b"] }()

// ✓ FIX: Use mutex
var (
    cache = make(map[string]string)
    mu    sync.Mutex
)
go func() { mu.Lock(); cache["a"] = "1"; mu.Unlock() }()
go func() { mu.Lock(); _ = cache["b"]; mu.Unlock() }()
MAP RUNTIME PROTECTION

Go’s map implementation detects some concurrent writes and panics with fatal error: concurrent map writes. This is separate from the race detector—it’s the map protecting itself from corruption. Don’t rely on this panic as protection; always synchronize access.

Pattern 3: Done Flag

fix_done.go
// Illustrative snippet — not a complete program
// ✗ RACE
var done bool
go func() { doWork(); done = true }()
for !done { time.Sleep(time.Millisecond) }

// ✓ FIX: Use channel
done := make(chan struct{})
go func() { doWork(); close(done) }()
<-done

Pattern 4: Lazy Initialization

fix_lazy_init.go
// Illustrative snippet — not a complete program
// ✗ RACE
var config *Config
func getConfig() *Config {
    if config == nil {
        config = loadConfig()
    }
    return config
}

// ✓ FIX (Go 1.21+): sync.OnceValue owns both the guard and the
// value, so there is no package-level variable left to race on.
var getConfig = sync.OnceValue(loadConfig)

// ✓ Same guarantee the long way, if you need to do more than
// return one value.
var (
    config     *Config
    configOnce sync.Once
)
func getConfigSlow() *Config {
    configOnce.Do(func() { config = loadConfig() })
    return config
}

Debugging Tips

Reproduce Flaky Races

Races are timing-dependent. If a race appears intermittently:

Terminal
# Run test repeatedly until race appears
$ while go test -race -run=TestFlaky; do :; done
# Or with count
$ go test -race -run=TestFlaky -count=100

When Races Aren’t Reported

If you suspect a race but the detector is silent:

  1. Check test coverage. Does the test exercise the racy code path?
  2. Run multiple times. Races are probabilistic: go test -race -count=100
  3. Widen race windows. Add artificial delays during debugging to increase overlap probability.
  4. Check feature flags. Is the racy code behind a flag that’s disabled in tests?

Races in Third-Party Code

If a race report points to library code:

  1. Check if you’re using the library correctly (most likely)
  2. Check if there’s a known issue in the library
  3. Report the bug if it’s genuinely in the library

Common Mistakes

Row
“Tests pass without -race
“I’ll add -race later”
“No races found = correct”
“I’ll ignore this race”
Running -race in production
Only running tests once

Key Takeaways

  1. Always use -race in development and CI—it’s your best defense against data races
  2. The detector finds executed races only—high test coverage is essential for reducing false negatives
  3. It detects data races, not race conditions—passing -race doesn’t prove correctness
  4. Fix every reported race—there are no false positives and no “acceptable” data races
  5. Read reports from your code outward—skip runtime internals, focus on your functions
  6. Multiple reports may share one cause—fix the first, re-run, and see which others disappear
  7. Avoid race-enabled builds in production—use them in development, CI, and staging/canary environments

Next: §8.4 covers the Go memory model—the formal rules that define when one goroutine’s writes become visible to another, and why synchronization is necessary for correct concurrent programs.

8.4 The Go Memory Model

§§8.1–8.3 established that data races cause undefined behavior and showed you how to detect them. But why is unsynchronized access so dangerous? Why can’t goroutines just read the latest value written by another goroutine?

The answer lies in Go’s memory model—a set of formal rules that define when a write in one goroutine is guaranteed to be visible to a read in another. Without synchronization, you have no guarantees about what values a goroutine will see. Not “probably correct,” not “eventually consistent”—no guarantees at all.

THE GO MEMORY MODEL

A statement of what the memory model is for: it answers when a write in one goroutine is guaranteed visible to a read in another, and the answer is when a happens-before relationship exists between them. Without one, writes may not become visible, reads may see stale or partial values, and word-sized reads still see some real write while wider values can tear. With one, writes are guaranteed visible to subsequent reads and behavior is predictable — and establishing that relationship is exactly what synchronization does.

THE MEMORY MODEL IS A CONTRACT

The Go memory model is a contract between you and the Go runtime:

  • You promise: To use proper synchronization when sharing data
  • Go promises: To make writes visible according to happens-before rules

Break your promise (write racy code), and Go’s promises are void—undefined behavior ensues.

GO 1.19 MEMORY MODEL REVISION

The Go memory model was significantly revised in Go 1.19 (2022) to formally document guarantees that were previously only informally understood—particularly for sync/atomic operations. This section reflects the current (post-1.19) specification. If you encounter older resources that say atomics don’t provide ordering guarantees, they are outdated.


Why Memory Models Exist

You might think: “If I write to a variable, then another goroutine reads it, it should see my write.” This intuition is wrong for three reasons:

1. Compiler Reordering

Recall the reordering example from §8.1—now we can explain why it happens. The compiler optimizes code by reordering operations that appear independent:

reordering.go
// Illustrative snippet — not a complete program
// What you wrote:
data = 42
ready = true

// What the compiler might generate:
ready = true
data = 42  // Reordered—no dependency between them

Within a single goroutine, this reordering is invisible—your code behaves as if operations happened in order. But another goroutine observing these variables might see ready = true before data = 42.

The compiler may also:

2. CPU Reordering

Modern CPUs execute instructions out of order for performance. Even if the compiler preserved your order, the CPU might not:

cpu_reorder.go
// Illustrative snippet — not a complete program
// Compiled order:
data = 42
ready = true

// CPU execution on a weakly-ordered machine (arm64, ppc64):
Store ready = true
Store data = 42  // Reordered; x86-64 is TSO and will not
                 // do this, which is why the same racy
                 // program can pass on a laptop and fail
                 // on an arm64 server.

3. Cache Visibility

Each CPU core has its own cache. A write might sit in one core’s cache without being visible to other cores:

MULTI-CORE CACHE ARCHITECTURE

Two CPU cores, each with its own L1 cache, both sitting above main memory. Core 1's cache holds the new values; core 2's cache still holds the old ones and is marked stale; main memory may reflect neither. Core 2 can keep reading its own stale cache while core 1's writes have not left core 1.

The memory model exists because modern hardware and compilers optimize aggressively. These optimizations are invisible within a single goroutine but become visible—and dangerous—when multiple goroutines share data.


The Happens-Before Relationship

The memory model is built on one core concept: happens-before.

Definition: If event A happens before event B, then A’s memory effects are guaranteed to be visible to B.

Key insight: Happens-before is established by synchronization operations, not by wall-clock time. Two operations can occur at different times but still have no happens-before relationship.

HAPPENS-BEFORE VS WALL-CLOCK TIME

Two scenarios contrasting happens-before with clock time. In the first, two goroutines write at the same instant, then one sends on a channel and the other receives; the send-receive pair creates happens-before, so the receiver is guaranteed to see the other's write despite there being no time gap. In the second, one goroutine writes and another reads a full second later with no synchronization between them; there is no happens-before, so the reader may see either value. Wall-clock ordering guarantees nothing.

Key properties of happens-before:

  1. Transitive: If A happens-before B, and B happens-before C, then A happens-before C
  2. Within a goroutine: Each statement happens-before the next (program order)
  3. Across goroutines: Only synchronization creates happens-before edges

What Establishes Happens-Before

Go’s memory model defines exactly which operations create happens-before relationships:

1. Within a Single Goroutine

Statements execute in program order. Each statement happens-before the next:

program_order.go
// Illustrative snippet — not a complete program
func singleGoroutine() {
    x := 1      // A
    y := x + 1  // B: A happens-before B
    z := y + 1  // C: B happens-before C
}

This is the only case where “earlier in code” means “happens before.”

2. Goroutine Creation

The go statement happens before the new goroutine starts:

goroutine_creation.go
// Illustrative snippet — not a complete program
x := 42
go func() {
    fmt.Println(x)  // Guaranteed to see x = 42
}()

Happens-before chain: x = 42go statement → goroutine’s first instruction.

3. Channel Operations

Send and receive: A send on a channel happens before the corresponding receive completes. This applies to both buffered and unbuffered channels.

channel_send_recv.go
// Illustrative snippet — not a complete program
var data int
ch := make(chan struct{})

go func() {
    data = 42         // (1)
    ch <- struct{}{}  // (2) send
}()

<-ch               // (3) receive—happens after (2)
fmt.Println(data)  // (4) guaranteed to see 42

Channel close: Close happens before a receive that returns the zero value due to closure.

channel_close.go
// Illustrative snippet — not a complete program
var data int
ch := make(chan struct{})

go func() {
    data = 42
    close(ch)  // Close happens before receive of zero value
}()

<-ch               // Returns immediately (channel closed)
fmt.Println(data)  // Guaranteed to see 42

Unbuffered channels (additional guarantee): For unbuffered channels, send and receive must rendezvous—neither completes until both are ready. This gives an additional guarantee: the receive happens-before the send completes. In practice, this means both sides can observe each other’s prior writes after the rendezvous:

unbuffered_sync.go
// Illustrative snippet — not a complete program
ch := make(chan int)  // Unbuffered
var data int

go func() {
    data = 42
    <-ch  // (1) Receive happens before send completes
}()

ch <- 1           // (2) Send completes after (1)
fmt.Println(data) // Guaranteed to see 42

This bidirectional synchronization doesn’t apply to buffered channels, where sends complete immediately if buffer space is available.

4. Mutex Operations

Unlock() happens before any subsequent Lock() on the same mutex:

mutex_hb.go
// Illustrative snippet — not a complete program
var (
    mu   sync.Mutex
    data int
)

mu.Lock()           // (1) Main acquires first
go func() {
    mu.Lock()        // (3) Blocks until (2)
    fmt.Println(data) // Guaranteed to see 42
    mu.Unlock()
}()

data = 42           // (1b) Write while holding lock
mu.Unlock()         // (2) Unlock happens-before goroutine's Lock
MUTEX HAPPENS-BEFORE

A timeline with two rows. The main goroutine locks, writes, and unlocks. An arrow labeled happens-before runs from that unlock down to a second goroutine's lock, after which it reads. The unlock-lock sequence is what creates the edge, and every write before the unlock is visible after the subsequent lock.

5. sync.Once

The function passed to Do() completes before any Do() call returns:

sync_once.go
// Illustrative snippet — not a complete program
var (
    once   sync.Once
    config *Config
)

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig()  // Happens before any Do() returns
    })
    return config  // Guaranteed to see initialized config
}

Since Go 1.21 there is a shorter spelling of exactly this. sync.OnceValue(loadConfig) returns a function with the same happens-before guarantee and no package-level variable to protect — the thing being guarded stops being reachable without going through the guard. sync.OnceFunc does the same for a function that returns nothing. Use the explicit sync.Once when the initializer has to set several things at once; use OnceValue the rest of the time.

6. sync.WaitGroup

Done() happens before Wait() returns:

waitgroup_hb.go
// Illustrative snippet — not a complete program
var (
    wg   sync.WaitGroup
    data int
)

wg.Add(1)
go func() {
    data = 42
    wg.Done()  // Happens before Wait returns
}()

wg.Wait()
fmt.Println(data)  // Guaranteed to see 42

This example keeps Add and Done visible because they are what the rule is stated in terms of. In your own code, wg.Go(func(){…}) — used everywhere else in this book since Go 1.25 — does the pair for you and carries the identical guarantee: whatever the function writes is visible after Wait returns.

7. Atomic Operations

Operations in sync/atomic establish happens-before. An atomic store happens before any atomic load that observes the stored value:

atomic_hb.go
// Illustrative snippet — not a complete program
var (
    data  int
    ready atomic.Bool
)

go func() {
    data = 42           // (1) Program order within goroutine
    ready.Store(true)   // (2) Atomic store
}()

for !ready.Load() {     // (3) Atomic load—loops until (2) observed
    time.Sleep(time.Microsecond)  // Brief sleep to avoid busy-spinning
}
fmt.Println(data)       // (4) Guaranteed to see 42

Happens-before chain:

ATOMICS PROVIDE TWO GUARANTEES

Atomic operations provide:

  1. Atomicity: The operation is indivisible (no torn reads/writes)
  2. Memory ordering: Establishes happens-before relationships

The second guarantee is why the ready/data pattern works—the atomic store of ready makes all preceding writes (including data = 42) visible to any goroutine that observes ready == true via atomic load.

Without both guarantees, you’d need a mutex even for simple flags.

8. Package Initialization

Package init() functions and variable initialization happen before main() starts:

pkg_init.go
// Illustrative snippet — not a complete program
var config = loadConfig()  // Happens before main()

func main() {
    use(config)  // Guaranteed to see initialized config
}
GOROUTINE EXIT IS NOT SYNCHRONIZATION

A goroutine finishing does not create a happens-before relationship with any other goroutine. To observe a goroutine’s results, you must use explicit synchronization—a channel, sync.WaitGroup, or another primitive from this list.


Happens-Before Summary

Row
Program order
Goroutine creation
Channel send
Channel close
Unbuffered channel
Mutex
sync.Once
sync.WaitGroup
Atomic store
Package init

What the Memory Model Does NOT Guarantee

Understanding the limits is as important as understanding the guarantees.

No Guarantee: Time-Based Ordering

no_time_ordering.go
// Illustrative snippet — not a complete program
x := 0

go func() {
    time.Sleep(time.Second)
    fmt.Println(x)  // NOT guaranteed to see 1
}()

x = 1
time.Sleep(2 * time.Second)  // "Surely it's visible by now"—WRONG

Sleep is not synchronization. There’s no happens-before relationship.

No Guarantee: Busy-Wait on Regular Variables

busy_wait_broken.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Regular bool has no ordering guarantee
var done bool
var result int

go func() {
    result = compute()
    done = true  // DATA RACE
}()

for !done {  // DATA RACE
    // spin
}
fmt.Println(result)

This is undefined behavior. The compiler might:

Fixes:

busy_wait_fix1.go
// Illustrative snippet — not a complete program
// ✓ FIX 1: Atomic operations
var done atomic.Bool
var result int

go func() {
    result = compute()
    done.Store(true)  // Atomic store establishes happens-before
}()

for !done.Load() {
    time.Sleep(time.Microsecond)  // Brief sleep to avoid busy-spinning
}
fmt.Println(result)  // Guaranteed to see computed result
busy_wait_fix2.go
// Illustrative snippet — not a complete program
// ✓ FIX 2: Channel (preferred—blocking is better than polling)
var result int
done := make(chan struct{})

go func() {
    result = compute()
    close(done)
}()

<-done
fmt.Println(result)  // Guaranteed to see computed result
CHANNEL VS ATOMIC FOR SIGNALING
  • Atomic: Use when you need to poll a flag frequently
  • Channel: Use when a one-time signal is sufficient (preferred in most cases—blocking is better than polling)

No Guarantee: Observing Partial Updates

Without synchronization, you might observe writes in a different order than they occurred:

partial_updates.go
// Illustrative snippet — not a complete program
var a, b int

go func() {
    a = 1
    b = 2
}()

go func() {
    if b == 2 {
        fmt.Println(a)  // Might print 0!
    }
}()

This is a data race—both goroutines access a and b without synchronization. Even observing b == 2 doesn’t guarantee a == 1 is visible:

All three scenarios are legal—no synchronization means no happens-before, which means no guarantees.


The Double-Checked Locking Anti-Pattern

A common mistake is attempting “double-checked locking” for lazy initialization:

double_checked_broken.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Double-checked locking
var instance *Singleton
var mu sync.Mutex

func GetInstance() *Singleton {
    if instance == nil {  // First check—no synchronization!
        mu.Lock()
        if instance == nil {
            instance = &Singleton{}
        }
        mu.Unlock()
    }
    return instance
}

The first nil check has no happens-before relationship with the write inside the lock. A goroutine might:

Fix: Use sync.Once

double_checked_fix.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: sync.Once handles all synchronization
var (
    instance *Singleton
    once     sync.Once
)

func GetInstance() *Singleton {
    once.Do(func() {
        instance = &Singleton{}
    })
    return instance
}

Why this works: The function in Do() happens-before Do() returns in any goroutine. All callers see the fully initialized instance.


Connecting the Memory Model to Data Races

Now we can precisely state why data races cause undefined behavior:

FROM MEMORY MODEL TO DATA RACE

A reconciliation of two definitions. Section 8.1 defined a data race as the same memory plus a write plus no synchronization; the memory model defines it as the same memory plus a write plus no happens-before. They are the same statement, because synchronization *is* the establishing of happens-before. Below, how the race detector uses that: it tracks every memory access and every synchronization operation, builds the happens-before graph, and reports a race wherever two concurrent accesses have no path between them.


Why the Compiler Can’t “Just Be Safe”

A common question: “Why doesn’t the compiler insert memory barriers everywhere?”

Answer: Performance. Memory barriers and synchronization are expensive.

hot_loop.go
// Illustrative snippet — not a complete program
// Hot loop—runs billions of times
for i := 0; i < 1_000_000_000; i++ {
    localSum += data[i]
}
globalSum = localSum

The compiler optimizes this aggressively:

If the compiler assumed every operation could be observed by another goroutine, these optimizations would be impossible. Performance would drop by 10–100× for some workloads.

Go’s contract: You mark shared data with synchronization; Go optimizes everything else aggressively. This is why data races cause undefined behavior—the compiler assumes no races exist.


Test Your Understanding

Example 1: Package-level initialization

ex84_1.go
// Illustrative snippet — not a complete program
var config = loadConfig()

func Handler(w http.ResponseWriter, r *http.Request) {
    use(config)
}

Safe? Yes—package-level variable initialization happens before main() starts, which happens before any HTTP handlers run. The handlers (which run in goroutines spawned by the server) are guaranteed to see the initialized config.

Example 2: Atomic flag guarding data

ex84_2.go
// Illustrative snippet — not a complete program
var ready atomic.Bool
var data int

go func() {
    data = 42
    ready.Store(true)
}()

for !ready.Load() {
    time.Sleep(time.Microsecond)
}
fmt.Println(data)

Safe? Yes—the atomic store happens before the load that observes true. Since data = 42 happens before the store (program order), it’s visible after the load.

Example 3: Goroutine exit without sync

ex84_3.go
// Illustrative snippet — not a complete program
var x int

go func() {
    x = 1
}()
time.Sleep(time.Second)
fmt.Println(x)

Safe? No—this is a data race. There’s no happens-before between the write (x = 1) and the read (fmt.Println(x)). Sleep doesn’t establish synchronization, and goroutine completion alone doesn’t create happens-before.

Fixes:

example3_fixes.go
// Illustrative snippet — not a complete program
// ✓ FIX: Use WaitGroup
var x int
var wg sync.WaitGroup

wg.Go(func() {
    x = 1
})
wg.Wait()
fmt.Println(x)  // Guaranteed to see 1

// ✓ FIX: Use channel
var x int
done := make(chan struct{})

go func() {
    x = 1
    close(done)
}()
<-done
fmt.Println(x)  // Guaranteed to see 1

Example 4: Transitive happens-before

ex84_4.go
// Illustrative snippet — not a complete program
var a, b int
ch1 := make(chan struct{})
ch2 := make(chan struct{})

go func() { a = 1; close(ch1) }()
go func() { <-ch1; b = 2; close(ch2) }()
<-ch2
fmt.Println(a, b)

Safe? Yes—happens-before chains compose through transitivity:

happens_before_chain.go
// Illustrative snippet — not a complete program
a=1 → close(ch1) → <-ch1 → b=2 → close(ch2) → <-ch2 → read a,b

Both values are guaranteed visible.


Common Mistakes

Row
Using time.Sleep for sync
Polling a bool flag
Double-checked locking
Assuming writes are instant
“It works on my machine”

Key Takeaways

  1. Happens-before defines visibility—not wall-clock time, not source code order across goroutines
  2. Across goroutines, only synchronization creates ordering—without it, compilers reorder, CPUs reorder, and caches hide writes
  3. Channel send happens-before receive completes—channels synchronize naturally
  4. Mutex unlock happens-before subsequent lock—protecting data and establishing order
  5. Atomics provide atomicity AND memory ordering—both guarantees matter (formalized in Go 1.19)
  6. sync.Once is for lazy initialization—never hand-roll double-checked locking
  7. Goroutine exit is not synchronization—use channels, WaitGroup, or other primitives to observe results

Next: §8.5 explores strategies for preventing data races—confinement, immutability, and synchronization—giving you a toolkit for designing race-free concurrent programs.

8.5 Preventing Data Races

§§8.1–8.4 explained what data races are, how they differ from race conditions, how to detect them, and why the memory model makes them dangerous. Now we turn to prevention: how do you design concurrent programs that are race-free by construction?

Recall the three conditions for a data race from §8.1:

  1. Two or more goroutines access the same memory location
  2. At least one access is a write
  3. The accesses are not synchronized

Remove any condition, and there’s no race. This gives us three prevention strategies:

THREE STRATEGIES FOR PREVENTING DATA RACES

A mapping from each of the three race conditions to the strategy that eliminates it. Same memory location is eliminated by confinement, at least one write by immutability, and no synchronization by adding synchronization.

Design for Confinement First

The easiest data race to fix is the one that never exists. Before reaching for mutexes or channels, ask: “Does this data really need to be shared?” Often the answer is no.


Strategy 1: Confinement

Confinement means restricting data access to a single goroutine. If only one goroutine can access a variable, concurrent access is impossible—no race can occur.

Lexical Confinement

Variables scoped to a single goroutine are automatically confined:

handler.go
// Illustrative snippet — not a complete program
func handler(w http.ResponseWriter, r *http.Request) {
    // Each handler call runs in its own goroutine
    userID := r.URL.Query().Get("user")  // Confined to this goroutine
    data := fetchData(userID)            // Confined to this goroutine
    render(w, data)
}

Variables userID and data are local to each handler invocation. Even though handlers run concurrently, each has its own copy. No shared state = race-free by construction.

Lexical confinement is the default in Go. Unless you explicitly share data via pointers, package variables, or closure captures, data is automatically confined.

Confinement When Using Goroutines

When passing data to a goroutine, pass by value to create a confined copy:

confinement.go
// Illustrative snippet — not a complete program
// ✗ WRONG on Go <1.22: one shared loop variable
for _, item := range items {
    go func() {
        process(item)  // DATA RACE on Go <1.22
    }()
}

// ✓ CORRECT on this book's Go 1.25 baseline: item is already
// per-iteration, so the plain closure confines it.
for _, item := range items {
    go func() {
        process(item)
    }()
}

// Also correct, and portable to pre-1.22 toolchains: pass it in.
for _, item := range items {
    go func(it Item) {
        process(it)
    }(item)
}

Which one to write. Go 1.22 made the loop variable per-iteration, so on this book’s baseline the middle form is confined and is what chapters 1–7 use throughout. Reach for the parameter form when you must build on a pre-1.22 toolchain, or when the value you want to confine is not the loop variable — passing it in is still the clearest way to say “this goroutine gets its own copy.”

Ownership Transfer via Channels

Channels naturally implement confinement through ownership transfer. When you send a value on a channel, you transfer ownership to the receiver:

ownership.go
// Illustrative snippet — not a complete program
func producer(out chan<- *Buffer) {
    for {
        buf := &Buffer{}
        buf.Fill()       // Producer owns buf
        out <- buf       // Ownership transfers to receiver
        // Producer must NOT use buf after this point
    }
}

func consumer(in <-chan *Buffer) {
    for buf := range in {
        // Consumer now owns buf exclusively
        buf.Process()
        buf.Clear()
    }
}
OWNERSHIP TRANSFER VIA CHANNEL

An ownership-transfer timeline across three columns. The producer allocates a buffer and fills it, then sends it on the channel; ownership transfers with the value. The producer is marked as no longer permitted to touch it. The consumer receives, acquires ownership, and processes and clears it. At any moment exactly one goroutine owns the buffer, so there is no concurrent access and no race.

Ownership Transfer Is a Convention

Go doesn’t enforce ownership transfer—it’s a design discipline. The compiler won’t stop you from using buf after sending it. You must design your code so that senders relinquish access.

ownership_violation.go
// Illustrative snippet — not a complete program
// The compiler allows this, but it's a DATA RACE:
buf := &Buffer{}
ch <- buf
buf.Fill()  // Violates ownership—but compiles!

Enforce ownership through code review, clear documentation, and the race detector.

Slice and Map Confinement Pitfall

Slices and maps are reference types. Passing them to a goroutine shares the underlying data:

slice_pitfall.go
// Illustrative snippet — not a complete program
data := []int{1, 2, 3}

go func() {
    data[0] = 100  // Modifies shared backing array
}()

fmt.Println(data[0])  // DATA RACE

To truly confine, either:

Ad-Hoc Confinement

Sometimes confinement is maintained by convention rather than compiler enforcement. In the example below, the channels themselves provide synchronization—the ad-hoc part is the convention about which goroutine sends and which receives:

worker_pool.go
// Illustrative snippet — not a complete program
type WorkerPool struct {
    // jobs is only written by Submit(), read only by workers
    jobs chan Job

    // results is only written by workers, read only by Collect()
    results chan Result
}
Ad-Hoc Confinement Is Fragile

Nothing prevents another goroutine from accessing confined data. Document confinement assumptions clearly:

worker_stats.go
// Illustrative snippet — not a complete program
type Worker struct {
    // stats is confined to the worker goroutine.
    // Do not access from other goroutines.
    stats WorkerStats
}

Strategy 2: Immutability

Immutability means data doesn’t change after initialization. If there are no writes, concurrent reads are always safe.

Initialize-Once Pattern

The most common approach: initialize data before any concurrent access, then only read:

config.go
// Illustrative snippet — not a complete program
// Package-level configuration—initialized before main()
var config = loadConfig()  // Happens before any goroutine starts

func Handler(w http.ResponseWriter, r *http.Request) {
    // Safe: config is never modified after init
    timeout := config.Timeout
    endpoint := config.Endpoint
    // ...
}

Package initialization happens before main() (§ 8.4), establishing happens-before with all subsequent code.

Immutable Value Types

Design types that cannot be modified after creation:

point.go
// Illustrative snippet — not a complete program
// Point is immutable—all fields are private, no mutating methods
type Point struct {
    x, y float64
}

func NewPoint(x, y float64) Point {
    return Point{x: x, y: y}
}

func (p Point) X() float64 { return p.x }
func (p Point) Y() float64 { return p.y }

// "Mutation" returns a new Point—original unchanged
func (p Point) Translate(dx, dy float64) Point {
    return Point{x: p.x + dx, y: p.y + dy}
}
Value Receivers Enforce Immutability

Using value receivers (not pointer receivers) helps maintain immutability—methods receive a copy and cannot mutate the original. Returning new values is the only way to “change” state.

IMMUTABLE VS MUTABLE DESIGN

Two versions of the same counter type. The mutable one has a pointer receiver whose Increment method mutates the field in place, which is race-prone. The immutable one has a value receiver whose Increment returns a new Counter rather than modifying the old, so it can be shared freely — at the cost of having to propagate the new value somewhere.

Copy-on-Read for Safe Sharing

When readers need shared data that occasionally changes, return copies:

config_manager.go
// Illustrative snippet — not a complete program
type ConfigManager struct {
    mu     sync.RWMutex
    config Config
}

func (cm *ConfigManager) Get() Config {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    return cm.config  // Returns a copy—caller gets immutable snapshot
}

func (cm *ConfigManager) Update(new Config) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    cm.config = new
}

Callers receive a copy they own—they can use it freely without synchronization.

Shallow Copy Warning

Returning a struct creates a shallow copy. If Config contains slices, maps, or pointers, those fields still reference shared data:

config_struct.go
// Illustrative snippet — not a complete program
type Config struct {
    Timeout time.Duration  // Safe: value type
    Servers []string       // Danger: backing array shared!
}

For reference types, return defensive copies:

config_servers.go
// Illustrative snippet — not a complete program
func (cm *ConfigManager) GetServers() []string {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    result := make([]string, len(cm.config.Servers))
    copy(result, cm.config.Servers)
    return result
}

Copy-on-Write

For data read frequently but written rarely, copy the entire structure on write:

registry.go
// Illustrative snippet — not a complete program
type Registry struct {
    mu       sync.RWMutex
    services map[string]Service
}

func (r *Registry) Get(name string) (Service, bool) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    svc, ok := r.services[name]
    return svc, ok
}

func (r *Registry) Register(name string, svc Service) {
    r.mu.Lock()
    defer r.mu.Unlock()

    // Copy-on-write: create new map with added entry
    newServices := make(map[string]Service, len(r.services)+1)
    for k, v := range r.services {
        newServices[k] = v
    }
    newServices[name] = svc
    r.services = newServices
}

// List returns a consistent snapshot—this is the CoW payoff.
// A concurrent Register() replaces the map pointer, but this
// iteration continues safely on the old (immutable) copy.
func (r *Registry) List() []string {
    r.mu.RLock()
    snapshot := r.services
    r.mu.RUnlock()

    names := make([]string, 0, len(snapshot))
    for name := range snapshot {
        names = append(names, name)
    }
    return names
}
Copy-on-Write with Mutex

This example combines mutex synchronization with copy-on-write. Each serves a different purpose:

  • Mutex synchronization: Coordinates access (prevents simultaneous reads during write)
  • Copy-on-write: Ensures readers who obtained a map reference before this write still see a consistent (old) snapshot

For truly lock-free reads (no mutex on read path), use atomic pointer swap (shown next).

COPY-ON-WRITE TIMELINE

A copy-on-write timeline in three steps. At T0 a map version 1 exists and two readers take references to it. At T1 a writer builds version 2 as a copy plus the new entry and swaps the pointer; the two existing readers keep using version 1 as a consistent snapshot while a third reader picks up version 2. At T2 the first two readers finish and version 1 becomes garbage. Every reader sees a complete, consistent snapshot.

Copy-on-Write Trade-offs

Pros: Readers see consistent snapshots; old references remain valid

Cons: Write cost is O(n) where n = number of entries; 2× memory during writes

Best for: Read-heavy workloads (reads >> writes), small to medium collections

Atomic Pointer Swap for Lock-Free Reads

The mutex-protected copy-on-write above requires acquiring a read lock for every access. For extremely read-heavy workloads (thousands of reads per write), you can eliminate read-side locking entirely using atomic pointer swap:

server_atomic.go
// Illustrative snippet — not a complete program
type Server struct {
    config atomic.Pointer[Config]
}

func NewServer(cfg *Config) *Server {
    s := &Server{}
    s.config.Store(cfg)
    return s
}

func (s *Server) Config() *Config {
    return s.config.Load()  // Lock-free read
}

func (s *Server) UpdateConfig(new *Config) {
    s.config.Store(new)  // Atomic swap
}

func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
    cfg := s.Config()  // Get immutable snapshot
    // Use cfg freely—even if UpdateConfig is called, our pointer
    // still points to the old config which remains valid
    timeout := cfg.Timeout
    // ...
}
Atomic Pointers Require Immutable Pointees

When using atomic.Pointer[T], the pointed-to value must be immutable. If callers can mutate the value, you’ve traded one race for another:

atomic_violation.go
// Illustrative snippet — not a complete program
cfg := server.Config()
cfg.Timeout = 100  // DATA RACE if another goroutine reads cfg

Make T immutable by design: unexported fields, no setter methods, no mutable reference-type fields.


Strategy 3: Synchronization

When data must be shared and mutable, use synchronization primitives to coordinate access. This establishes the happens-before relationships covered in §8.4.

Choosing the Right Primitive

Choosing the Right Primitive
Row
Communication, data transfer
Complex shared state
Many readers, few writers
Simple counters and flags
One-time initialization
Wait for completion

Brief Examples

sync_examples.go
// Illustrative snippet — not a complete program
// Channels: Communication and ownership transfer
results := make(chan Result)
go func() { results <- process(item) }()
result := <-results

// Mutex: Complex shared state
var mu sync.Mutex
var balance int

mu.Lock()
balance += 100
mu.Unlock()

// Atomic: Simple counters (lower overhead than mutex)
var counter atomic.Int64
counter.Add(1)

// Once: Lazy initialization
var once sync.Once
var instance *Service

once.Do(func() { instance = newService() })
Keep Critical Sections Small

The shorter the critical section, the better the concurrency:

cache.go
// Illustrative snippet — not a complete program
// ✗ BAD: Holding lock during slow I/O
func (c *Cache) GetOrLoad(key string) string {
    c.mu.Lock()
    defer c.mu.Unlock()
    if val, ok := c.data[key]; ok {
        return val
    }
    val := loadFromDatabase(key)  // Slow! Lock held entire time
    c.data[key] = val
    return val
}

// ✓ GOOD: Only lock for map access
// Note: Two goroutines may both miss the cache and load the same key.
// This is a race condition (duplicate work), not a data race.
// For most caches, duplicate loads are acceptable.
func (c *Cache) GetOrLoad(key string) string {
    c.mu.Lock()
    val, ok := c.data[key]
    c.mu.Unlock()

    if ok {
        return val
    }

    val = loadFromDatabase(key)  // No lock during I/O

    c.mu.Lock()
    c.data[key] = val
    c.mu.Unlock()
    return val
}

Detailed synchronization patterns are covered in Chapters 9–12.


Choosing a Strategy

DECISION FLOWCHART

A decision flowchart. Ask first whether the data can be confined to a single goroutine; if yes, use confinement via goroutine-local variables, ownership transfer or lexical scoping. If not, ask whether it is read-only after initialization; if yes, use immutability via initialize-before-start, copy-on-read snapshots or an atomic pointer swap. If neither, synchronize — channels for communication, a mutex for complex state, an RWMutex when reads dominate, atomics for simple values, and sync.Once for one-time initialization.

Strategy by Scenario
Row
Pipeline processing
Request-scoped data
Application config
Cache entries
Request counter
Work distribution
Shared map

Combining Strategies

Real programs combine all three strategies, choosing the right approach for each piece of data:

server.go
// Illustrative snippet — not a complete program
type Server struct {
    config     *Config             // IMMUTABLE after construction
    addr       string              // IMMUTABLE after construction
    shutdownCh chan struct{}       // SYNCHRONIZED via channel ops
    totalReqs  atomic.Int64        // SYNCHRONIZED with atomics
    mu         sync.RWMutex        // Protects sessions below
    sessions   map[string]*Session // SYNCHRONIZED with mutex
}

func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
    // Atomic: simple increment
    s.totalReqs.Add(1)

    // Immutable: config never changes after construction
    timeout := s.config.Timeout

    // Confined: request data is local to this handler
    ctx, cancel := context.WithTimeout(r.Context(), timeout)
    defer cancel()

    // Synchronized: session map requires mutex
    s.mu.RLock()
    session := s.sessions[getSessionID(r)]
    s.mu.RUnlock()

    // Process request...
}
COMBINING STRATEGIES

One server split into three pieces of state, each with a different strategy: an immutable config, an atomic request counter, and a mutex-guarded session map. Each component uses whatever suits its access pattern, and the three compose without interfering.


The Four Questions Revisited

Chapter 2 introduced the Four Questions for every goroutine. Question 4—“What data does it access?”—now has a complete framework:

Question 4: What Data Does It Access?
Row
Is it shared?
Is it modified after init?
What synchronization protects it?

If you can’t answer how shared mutable data is protected, you have a potential race.


Anti-Patterns to Avoid

Shared-state signaling: As we saw in §8.4, polling a regular bool variable is a data race—use a channel or atomic.Bool instead.


Common Mistakes

Using Data After Channel Send
Problem

Ownership transferred—you no longer own it

Fix

Never access after sending

“Immutable” Struct with Pointer Fields
Problem

Pointed-to data can still change

Fix

Deep copy or use value types

Mixing Strategies Inconsistently
Problem

Partial protection is no protection

Fix

One strategy per data item

Assuming “Quick” Access Is Safe
Problem

Even single reads can race

Fix

Always synchronize shared mutable data

Over-Synchronizing
Problem

Performance cost, complexity

Fix

Try confinement/immutability first


Verify Your Strategy

After implementing any of these strategies, run your tests with -race to verify there are no hidden races:

Terminal
$ go test -race ./...

The race detector (§8.3) is your safety net—use it to confirm your design is correct.


Test Your Understanding

Example 1

connection_handler.go
// Illustrative snippet — not a complete program
func handleConnection(conn net.Conn) {
    defer conn.Close()
    buffer := make([]byte, 4096)
    for {
        n, err := conn.Read(buffer)
        if err != nil {
            return
        }
        process(buffer[:n])
    }
}
What strategy is being used?

Confinement. buffer is local to handleConnection—each connection handler has its own.

Example 2

config_timeout.go
// Illustrative snippet — not a complete program
var config = &Config{Timeout: 30 * time.Second}

func getTimeout() time.Duration {
    return config.Timeout
}
What strategy is being used?

Immutability (by convention). config is initialized at package level and never modified, only read. This is safe as long as no code ever writes to config after initialization.

Example 3

rate_limiter.go
// Illustrative snippet — not a complete program
type RateLimiter struct {
    mu     sync.Mutex
    tokens int
}

func (r *RateLimiter) Allow() bool {
    r.mu.Lock()
    defer r.mu.Unlock()
    if r.tokens > 0 {
        r.tokens--
        return true
    }
    return false
}
What strategy is being used?

Synchronization (mutex). Rate limiter state must be shared and mutated by multiple goroutines.

Example 4

worker_race.go
// Illustrative snippet — not a complete program
var totalProcessed int

func worker(items <-chan Item) {
    for item := range items {
        process(item)
        totalProcessed++
    }
}

// Launch 10 workers
for i := 0; i < 10; i++ {
    go worker(items)
}
What strategy is being used?

None—this is a DATA RACE. Multiple workers write totalProcessed without synchronization. Fix with atomic.Int64 (preferred for simple counters—lower overhead than mutex):

worker_fixed.go
// Illustrative snippet — not a complete program
var totalProcessed atomic.Int64

func worker(items <-chan Item) {
    for item := range items {
        process(item)
        totalProcessed.Add(1)
    }
}

Summary

Prevention Strategy Comparison
Row
Confinement
Immutability
Synchronization

Key Takeaways

  1. Three strategies map to three conditions—confinement (no sharing), immutability (no writes), synchronization (coordinated access)
  2. Prefer confinement first—if only one goroutine accesses data, no race is possible and there’s zero runtime cost
  3. Ownership transfer via channels—the idiomatic Go way to move data between goroutines safely
  4. Beware shallow copies—slices, maps, and pointers share underlying data even after struct copy
  5. Synchronization is the last resort—necessary for shared mutable state, but keep critical sections small
  6. Combine strategies per component—config (immutable), counters (atomic), sessions (mutex), requests (confined)
  7. Verify with the race detector—always run go test -race to confirm your design

Next: Test your understanding with the chapter self-check below.


Chapter 8 Self-Check

Test your understanding of the complete chapter with these questions. Click each question to reveal the answer.

1. What are the three conditions that must all be true for a data race to exist?

Three conditions: (1) Two or more goroutines access the same memory location, (2) At least one access is a write, (3) The accesses are not synchronized. All three must be true simultaneously.

2. A function uses sync.Mutex to protect all reads and writes to a map. The race detector reports no races. Can the code still have a race condition?

Yes, it can still have a race condition. A mutex prevents data races (unsynchronized memory access) but not race conditions (timing-dependent correctness). For example, check-then-act patterns can have race conditions even when each individual operation is mutex-protected—if the mutex is released between the check and the act, another goroutine can intervene.

3. What’s the difference between “the write happens before the read” (wall-clock time) and “the write happens-before the read” (memory model)?

Wall-clock time means one event occurred earlier in real time. Happens-before is a formal guarantee from the memory model that one operation’s effects are visible to another. Wall-clock ordering provides no visibility guarantees—a write at T=0 is not guaranteed to be visible to a read at T=1 without synchronization. Happens-before requires explicit synchronization (channels, mutexes, atomics).

4. You see this pattern in code review. What’s wrong, and how would you fix it?

ready_race.go
// Illustrative snippet — not a complete program
var ready bool
var data int

go func() {
    data = 42
    ready = true
}()

for !ready {}
fmt.Println(data)
Show Answer

Two problems: (1) Data race on both ready and data—unsynchronized concurrent access. (2) The compiler may cache ready in a register, causing an infinite loop. Fixes: Use atomic.Bool for ready (which also establishes happens-before for data), or use a channel to signal completion (preferred—blocking beats polling).

5. A colleague says “I only read the variable once, so there’s no race.” Why is this reasoning incorrect?

One read + one write = race. A data race requires at least one write and one read (or two writes) to the same location without synchronization. It doesn’t matter how many times each goroutine accesses the variable—even a single unsynchronized read racing with a single write is undefined behavior.

6. Which of these establishes a happens-before relationship?

Show Answer

b) and d) Channel send/receive and mutex Lock/Unlock establish happens-before relationships. time.Sleep does not create happens-before—it’s just a delay, not synchronization. Earlier wall-clock time provides no memory model guarantees.

7. You have configuration data that’s read by every HTTP handler but only updated once per hour via admin endpoint. What prevention strategy would you use?

Immutability with atomic pointer swap. Since reads vastly outnumber writes (every request vs. once per hour), use atomic.Pointer[Config] to allow lock-free reads. On update, create a new Config struct and atomically swap the pointer. The pointed-to config must be immutable after creation.

8. The race detector passes on all tests. Does this prove absence of races?

No, it doesn’t prove absence of races. The race detector only finds races that actually execute during the test run. Races in untested code paths, races that require specific timing, or races that only manifest under production load won’t be detected. Passing -race is necessary but not sufficient—it proves no races were observed, not that none exist.

9. What’s the difference between the race detector’s exit code 0 and exit code 66?

Exit code 0: tests passed, no races detected. Exit code 66: the race detector’s own code, reported when you run a go build -race binary directly. go test -race and go run -race wrap that binary and exit 1 instead — go run prints exit status 66 on its way out, which is where the confusion comes from. Standalone -race binaries log races to stderr and keep running by default, so they reach 66 only at exit. Have CI fail on any non-zero status rather than on 66.

10. For each scenario, identify the best prevention strategy (confinement, immutability, or synchronization):

Show Answer
  • a) Request-scoped variables: Confinement—each handler goroutine has its own local variables
  • b) Application version string: Immutability—set once at startup, never modified, read everywhere
  • c) Active user session count: Synchronization—multiple goroutines increment/decrement, use atomic.Int64

Chapter 8 Complete. You now understand data races comprehensively:

Next chapter: Chapter 9 covers sync.Mutex and sync.RWMutex—protecting shared state with mutual exclusion, patterns for correct usage, and common pitfalls to avoid.


Exercise 8.1 — Make the Handover Real

Your move

Give the two writes an edge the reader can stand on

Publisher hands one value from a producer to a consumer using a plain bool and a plain int. Nothing in the file establishes happens-before between the two writes and the two reads, so a reader can see done without seeing data.

This is the first exercise in the book whose starter passes go test. Run it and it will tell you everything is fine, ten times out of ten. Run go test -race and it fails every time. That gap is the chapter in one command — a green test suite says nothing about whether your code is race-free unless the detector was switched on.

ch08/publisher.go
package ch08

// TODO(reader): Publisher hands one value from a producer to a
// consumer goroutine. It works, most of the time, on most machines
// — which is the whole problem.
//
// `done` is a plain bool and `data` is a plain int, and nothing in
// this file establishes a happens-before relationship between the two
// writes in Publish and the two reads in Wait. §8.4 lists everything
// that would: a channel, a mutex, sync.Once, a WaitGroup, an atomic.
// This file uses none of them.
//
// Two consequences, and the tests below check for both:
//
//  1. `go test -race` reports a data race. That gate does not care
//     how lucky your timing was.
//  2. Wait can observe `done == true` while `data` is still 0,
//     because nothing orders the two writes against the two reads.
//
// Fix it with any mechanism from §8.4 that gives you a happens-before
// edge. Do not add a sleep: sleeping is not synchronization (§8.4,
// "No Guarantee: Time-Based Ordering"), and the race detector will
// still flag it.
type Publisher struct {
	data int
	done bool
}

func (p *Publisher) Publish(v int) {
	p.data = v
	p.done = true
}

// Wait blocks until Publish has been called, then returns the value.
func (p *Publisher) Wait() int {
	for !p.done {
		// spin
	}
	return p.data
}
ch08/publisher_test.go
package ch08

import (
	"testing"
	"time"
)

// The value handed over must be the value published. A torn handover
// shows up here as a zero: Wait saw done before it saw data.
func TestPublisherHandsOverTheValue(t *testing.T) {
	for round := 0; round < 200; round++ {
		p := &Publisher{}
		got := make(chan int, 1)
		go func() { got <- p.Wait() }()
		go p.Publish(42)

		select {
		case v := <-got:
			if v != 42 {
				t.Fatalf("round %d: Wait returned %d, want 42. "+
					"Wait saw done before it saw data — nothing "+
					"ordered the writes against the reads. See §8.4.",
					round, v)
			}
		case <-time.After(2 * time.Second):
			t.Fatal("Wait never returned: the reader is still " +
				"spinning on a value it may never observe. §8.4.")
		}
	}
}

// Several publishers and readers at once. This is the shape the race
// detector needs to see the conflicting accesses.
func TestPublisherUnderConcurrentLoad(t *testing.T) {
	for round := 0; round < 100; round++ {
		p := &Publisher{}
		done := make(chan int, 4)
		for i := 0; i < 3; i++ {
			go func() { done <- p.Wait() }()
		}
		go p.Publish(7)

		deadline := time.After(2 * time.Second)
		for i := 0; i < 3; i++ {
			select {
			case v := <-done:
				if v != 7 {
					t.Fatalf("round %d: got %d, want 7", round, v)
				}
			case <-deadline:
				t.Fatal("a reader never returned. See §8.4.")
			}
		}
	}
}
Done when: go test -race ./... in code/ch08/ reports ok. Use any mechanism from §8.4 that gives you a happens-before edge. Do not add a time.Sleep: sleeping is not synchronization, and the detector will say so.
Hint, if you want one: the smallest fixes are a channel close and a mutex, and both take about three lines. An atomic.Bool flag works too — §8.4’s “Atomic flag guarding data” explains why the plain data write rides along with it. Whichever you pick, the write you want published has to happen before the operation that creates the edge; put it after and the detector will tell you so at once.
Where the files are: labs/go-concurrency/code/ch08/. A worked answer sits in solution/publisher.go.txt.

Further reading

Next

You can now say what a data race is, what Go actually promises about one, how to make the detector show you where it is, and which of the three strategies removes it. The third strategy — synchronization — has so far been a single word covering five different tools. Chapter 9 opens it up: sync.Mutex and sync.RWMutex, what the read-write split actually buys you and when it costs more than it saves, and the lock-ordering discipline that keeps mutual exclusion from turning into the deadlocks of chapter 10.