What is a race condition, and how does Go's race detector help find them?

5 minintermediategorace-detectorconcurrencytesting

Quick Answer

A data race happens when two or more goroutines access the same memory location concurrently, at least one of them is a write, and there's no synchronization ordering the accesses — the result is undefined behavior, not just "maybe the wrong value." Go ships a built-in race detector, enabled with the -race flag on go run, go test, or go build, that instruments memory accesses at runtime and reports the exact goroutines, stack traces, and memory location involved in a detected race. The race detector only catches races that actually occur during a specific run, so it can't prove a program race-free. It only proves a particular execution had no races. Running tests under -race in CI is standard practice precisely because concurrency bugs are often intermittent and hard to reproduce otherwise.

Detailed Answer

A data race in Go isn't just "you might read a stale value." It's genuinely undefined behavior, which is why the race detector is considered essential tooling, not optional.

A race, and what -race reports

func main() {
    counter := 0
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++   // unsynchronized read-modify-write: a data race
        }()
    }
    wg.Wait()
    fmt.Println(counter)  // often not 1000, and the race itself is the real bug
}
go run -race main.go
WARNING: DATA RACE
Read at 0x00c0000140a0 by goroutine 8:
  main.main.func1()
      main.go:12 +0x3c
Previous write at 0x00c0000140a0 by goroutine 7:
  main.main.func1()
      main.go:12 +0x52

The detector pinpoints the exact line and both goroutines involved, which is far more actionable than debugging an intermittent wrong answer by hand.

Fixing it

var mu sync.Mutex
counter := 0
// ... inside the goroutine:
mu.Lock()
counter++
mu.Unlock()

Or, for a simple counter, sync/atomic:

var counter int64
atomic.AddInt64(&counter, 1)

The detector's real limitation

-race only flags races that happen during the specific execution you ran. A race that only triggers under a rare goroutine interleaving might not show up in a quick test run, but could still exist in the code. Running the race detector broadly, and running concurrency-heavy tests repeatedly under load, meaningfully increases the odds of catching a race. It's still not a formal proof of race-freedom, the way a static analyzer might aim for.

Related Resources