Data Races in Go Explained With Real Reproducible Examples
What a data race is in Go, with examples you can run: lost counter updates, vanished appends, inconsistent reads, and the three ways to remove each race.

A data race is the concurrency bug you can write in five lines and fail to notice for a year. The program compiles, the tests pass on a quiet laptop, and then production produces a total that is slightly off, or a map that panics once a week. The cause is always the same, and it is easier to understand once you have watched it happen rather than read a definition. This article starts with the definition from the Go memory model, then runs four small programs that each lose or corrupt something, and shows the fix for each. It is a companion to the race detector, which finds these automatically, and belongs to the concurrency patterns cluster. All the programs ran on Go 1.25.6.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64), 10 cores
The definition
The Go memory model defines a data race as a write to a memory location happening concurrently with another read or write to that location, unless all the accesses are atomic operations from sync/atomic. Break that down and every piece matters:
- The same memory location. Two goroutines using two different variables never race, however busy they are.
- At least one write. Any number of goroutines can read the same value at once safely, as long as nobody writes it.
- Concurrent. Neither access happens before the other according to the memory model's rules: a channel send before its receive, an unlock before the next lock, the start of a goroutine after the
gostatement. If there is no such ordering, the accesses are concurrent even if one of them "clearly" runs first on your machine.
When all three hold, the behavior is not "one of two outcomes". The memory model says what a racy read may observe, and for values larger than a machine word it may be a mix of two writes. The compiler is also free to reorder or cache accesses on the assumption that no one else is touching the location. So "it is only a counter" and "it is only a boolean" are not safe sentences. Why a data race is never benign goes through the common excuses one at a time.
Example 1: the lost update
The textbook race is a shared counter. Eight goroutines each add 100,000 to the same integer, so the answer should be 800,000:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
for range 100_000 {
counter++
}
})
}
wg.Wait()
fmt.Println("expected 800000, got", counter)
fmt.Println("updates were lost:", counter < 800000)
}expected 800000, got 296549
updates were lost: trueOn this machine the total came out at 296,549 instead of 800,000, and it is different on every run. counter++ looks like one operation and compiles to three: load the current value into a register, add one, store it back. If goroutine A and goroutine B both load 41 before either stores, both store 42, and one increment is gone. With eight goroutines hammering the same word, that interleaving happens constantly. Nothing crashed and nothing printed a warning; the result is just wrong, which is the usual shape of this bug.
The fixes, in the order I try them:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
// Fix 1: a mutex around the shared variable.
var mu sync.Mutex
locked := 0
for range 8 {
wg.Go(func() {
for range 100_000 {
mu.Lock()
locked++
mu.Unlock()
}
})
}
wg.Wait()
// Fix 2: an atomic, for a single value.
var atomicCount atomic.Int64
for range 8 {
wg.Go(func() {
for range 100_000 {
atomicCount.Add(1)
}
})
}
wg.Wait()
// Fix 3: no sharing. Each goroutine counts privately and sends one number.
results := make(chan int, 8)
for range 8 {
go func() {
n := 0
for range 100_000 {
n++
}
results <- n
}()
}
total := 0
for range 8 {
total += <-results
}
fmt.Println("mutex :", locked)
fmt.Println("atomic:", atomicCount.Load())
fmt.Println("owned :", total)
}mutex : 800000
atomic: 800000
owned : 800000All three give 800,000. They differ in what they cost and what they scale to. The atomic is the cheapest for one integer. The mutex is the general tool and the one to use when several variables must change together. The third removes the shared state entirely, and it is usually the fastest because no goroutine ever waits on another; the cost is a design where results are combined at the end. Counters with atomics, mutexes and sharding puts numbers on the three.
Example 2: the vanishing append
Appending to a slice looks like a single call and is not. append reads the slice header (pointer, length, capacity), may allocate a new array, writes the element, and returns a new header that the caller stores back. Share the slice variable between goroutines and every one of those steps races:
package main
import (
"fmt"
"sync"
)
func main() {
var items []int
var wg sync.WaitGroup
for g := range 8 {
wg.Go(func() {
for i := range 10_000 {
items = append(items, g*10_000+i)
}
})
}
wg.Wait()
fmt.Println("expected 80000 items, got", len(items))
fmt.Println("elements were lost:", len(items) < 80000)
}expected 80000 items, got 20495
elements were lost: trueElements disappear because two goroutines can read the same length, both write their element at that index, and both store a header with length+1, so one element overwrote the other. A growth step makes it worse: one goroutine copies the old array into a new one while another is still writing into the old one, and those writes go to an array nobody will read again. The program does not panic; it ends with 20,495 elements here instead of 80,000. The fix is a mutex around the append, or better, give every goroutine its own slice and merge them afterwards, or write to a preallocated slice where each goroutine owns a distinct index range (safe concurrent slices and maps covers those layouts).
Example 3: the half-updated pair
Races do not need to lose writes to be harmful. Two fields that must change together can be observed in the middle of a change. Here a writer keeps low and high such that high is always low + 1, and a reader checks the invariant:
package main
import (
"fmt"
"sync"
)
type pair struct{ low, high int }
func main() {
var p pair
var wg sync.WaitGroup
stop := make(chan struct{})
broken := 0
wg.Go(func() {
for i := 0; ; i++ {
select {
case <-stop:
return
default:
p.low = i
p.high = i + 1
}
}
})
for range 2_000_000 {
low, high := p.low, p.high
if high != low+1 {
broken++
}
}
close(stop)
wg.Wait()
fmt.Println("reader saw a broken invariant:", broken > 0)
}reader saw a broken invariant: trueThe reader sees high from one iteration and low from the next, so the pair is inconsistent. In real code the pair is a price and a currency, a start and an end, or a slice's pointer and length. The fix here is a mutex held across both writes and both reads, or storing the whole pair as an immutable value behind an atomic.Pointer[pair] so readers always see one complete version. Large values make this worse than it looks: a string or interface is two machine words, and a racy read can pair the pointer of one with the length of another, which is how a race turns into a crash rather than a wrong number.
Example 4: the one the compiler helps
A data race is not just a timing problem; the compiler is entitled to assume there is none. A boolean flag that one goroutine sets and another polls is the classic case. Without synchronization nothing obliges the polling loop to reload the variable, and an optimizing compiler may read it once and spin on the register. The Go memory model's own text on this uses the pattern as an example of a broken program. Whether a given compiler version miscompiles it varies, and that unpredictability is the lesson: the program is incorrect by specification, so it can break after a Go upgrade or when an unrelated function gets inlined. Use atomic.Bool or close a channel, which also carry the ordering guarantee that makes the data visible.
What the three fixes have in common
Every correct fix creates a happens-before edge between the writer and the reader. A mutex's Unlock happens before the next Lock. A channel send happens before the matching receive completes. An atomic store is seen by a later atomic load of that value. sync.WaitGroup's Wait returns after every Done. Whenever you are unsure whether two accesses are ordered, ask which of these edges connects them. If none does, you have a race, whether or not it has ever shown up.
The design fix beats the synchronization fix whenever you can manage it. Data owned by one goroutine, communicated by value over a channel, needs no lock (share memory by communicating, and the actor style take that idea further). The next step is to make your tests find races for you: run them with -race, in CI, always. The tool and how to read its reports is the subject of the race detector in Go, and the official race detector documentation lists exactly what it can and cannot see.


