A data race happens when two or more goroutines access the same memory at the same time, at least one of them is writing, and there’s no synchronization between them.
All three conditions have to be true. Two goroutines only reading? Fine. One goroutine? Fine. Properly locked? Fine. But concurrent unsynchronized access with a write in the mix — that’s a race.
Why they’re so nasty
Data races are the worst kind of bug because they’re nondeterministic. The program might work perfectly 999 times and corrupt data the 1000th. It depends on exact timing, which CPU core ran what, and the mood of the scheduler.
In simple language, it’s like two people editing the same Google Doc cell at the exact same instant with no locking — whoever’s write lands last wins, and you can’t predict who that’ll be. The result is corrupted state, lost updates, or a crash, and it almost never reproduces when you’re watching.
A racy example
Here’s the classic. Many goroutines incrementing one counter with no lock.
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // RACE: read-modify-write with no synchronization
}()
}
wg.Wait()
fmt.Println(counter) // almost never 1000
}
counter++ looks atomic but it’s actually three steps: read, add one, write back. Two goroutines can read the same value, both add one, and both write it back — so two increments become one. We lose updates.
The race detector
This is the headline tool, and interviewers love that Go ships it built in. Add the -race flag and Go instruments your program to detect races at runtime.
go run -race main.go # run with detection
go test -race ./... # test suite with detection
go build -race # build an instrumented binary
When it catches one, it prints exactly which goroutines touched which memory and where, including stack traces. It only finds races on code paths that actually execute, so run it against your tests and real workloads. It’s slower and uses more memory, so it’s a dev/CI tool, not for production.
Fixing it — option 1: mutex
Guard the shared variable with a lock so only one goroutine touches it at a time.
var (
mu sync.Mutex
counter int
)
func inc() {
mu.Lock()
defer mu.Unlock()
counter++ // now safe — only one goroutine in here at a time
}
For a single counter, atomic.AddInt64 is an even cheaper fix.
Fixing it — option 2: channel
Instead of sharing the counter, give one goroutine ownership and have others send it work. Share memory by communicating.
counts := make(chan int)
go func() {
total := 0
for range counts { // only THIS goroutine touches total
total++
}
}()
// other goroutines just do: counts <- 1
Only one goroutine ever reads or writes total, so there’s nothing to race.
The loop-variable classic
We mentioned this in the goroutines note, and it’s a genuine data race in older Go. Before Go 1.22, every goroutine captured the same loop variable, and the loop kept writing to it while the goroutines read it — concurrent unsynchronized access.
// Go 1.21 and earlier — racy
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // reads i while the loop writes it
}()
}
Run that with -race on old Go and it lights up. The fix is to copy the variable per iteration (i := i) or pass it as an argument. Go 1.22 fixed the scoping so each iteration gets its own variable.
Interview soundbite
A data race is concurrent access to the same memory where at least one access is a write and there’s no synchronization. They’re brutal because they’re nondeterministic and rarely reproduce. Go’s built-in race detector (go run -race, go test -race) finds them on executed code paths with full stack traces. Fix them by guarding state with a mutex/atomic, or by giving one goroutine ownership and communicating via channels.