Race Conditions in Go: Detecting Them and Fixing with Mutex and Atomics
See a data race produce 20 different answers, detect it with -race, and fix it with sync.Mutex, RWMutex or sync/atomic.
On this page
When two goroutines access the same variable at the same time and at least one of them writes, you have a data race. The result is unpredictable, and the bug may only show up in production under load. This article shows a race happening, how to detect it, and three ways to fix it.
A race you can see
Add the numbers from 1 to 1000, each addition in its own goroutine:
func unsafeSum() int {
var wg sync.WaitGroup
sum := 0
for i := 1; i <= 1000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sum = sum + i // read, add, write: NOT atomic
}(i)
}
wg.Wait()
return sum
}The correct answer is 500500. I ran this 20 times on Go 1.27 and got 20 different wrong answers.
Why? sum = sum + i is three steps: read sum, add i, write sum. Two goroutines can both read the same old value, both add, and both write. One update is lost:
goroutine A: read sum (100)
goroutine B: read sum (100)
goroutine A: write 100 + 5 -> sum = 105
goroutine B: write 100 + 7 -> sum = 107 (A's +5 is lost)Detect races with -race
Go ships with a race detector. Use it in tests and during development:
go run -race .
go test -race ./...==================
WARNING: DATA RACE
Read at 0x00c0001a4038 by goroutine 9:
main.main.func1()
race/main.go:15 +0x84
Previous write at 0x00c0001a4038 by goroutine 8:
main.main.func1()
race/main.go:15 +0x9b
==================
Found 2 data race(s)-race in CI. It slows tests down a little, but it catches bugs that are almost impossible to find by reading code.Fix 1: sync.Mutex
A mutex (mutual exclusion lock) lets only one goroutine at a time execute the code between Lock and Unlock, the critical section.
func mutexSum() int {
var wg sync.WaitGroup
var mu sync.Mutex
sum := 0
for i := 1; i <= 1000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
sum += i // only one goroutine at a time gets here
}(i)
}
wg.Wait()
return sum // 500500, every time
}Guidelines:
defer mu.Unlock() right after Lock() so an early return or panic cannot leave it locked.Fix 2: sync.RWMutex for read-heavy data
When many goroutines read and few write (a cache, a config, a product list), an RWMutex allows any number of readers at once, but writers get exclusive access:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}Fix 3: atomic operations
For a single number such as a counter, sync/atomic is faster and simpler:
var sum atomic.Int64
var wg sync.WaitGroup
for i := 1; i <= 1000; i++ {
wg.Go(func() { sum.Add(int64(i)) }) // wg.Go: Go 1.25+
}
wg.Wait()
fmt.Println(sum.Load()) // 500500Atomics only protect a single value. As soon as you need to update two things together (for example, a balance and a transaction log), use a mutex.
Or avoid sharing entirely
The other Go approach is to not share memory at all: give each goroutine its own data and send results over a channel to one goroutine that combines them. For the sum example, each worker could compute a partial sum and send it back. Choose whichever is clearer:
| Use | When |
|---|---|
sync.Mutex / RWMutex | protecting shared state (caches, maps, structs) |
sync/atomic | single counters, flags, gauges |
| channels | passing ownership of data, pipelines, worker pools |
Key takeaways
x = x + 1 is not atomic; updates get lost.go test -race routinely to detect races.RWMutex for read-heavy data, atomics for single counters.Lock with defer Unlock.Last updated on