Fix Go 'WARNING: DATA RACE' โ€” Race Condition Detected

intermediate๐Ÿ”ท Go2026-03-18| Go 1.16+, Linux / macOS / Windows โ€” any platform running the Go race detector

Error Message

WARNING: DATA RACE
#go#race#concurrency#goroutine

The Error

You ran your Go program with the race detector enabled and got hit with something like this:

==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
  main.incrementCounter()
      /home/user/app/main.go:15 +0x30

Previous read at 0x00c0000b4010 by goroutine 6:
  main.readCounter()
      /home/user/app/main.go:22 +0x2e

Goroutine 7 (running) created at:
  main.main()
      /home/user/app/main.go:35 +0x6e
==================
Found 1 data race(s)
exit status 66

Or the race detector fires mid-test during go test -race ./... and your CI pipeline turns red. Either way, this is real trouble โ€” silent data corruption in production is hard to debug and even harder to reproduce.

Why This Happens

A data race occurs when two goroutines access the same memory location concurrently and at least one of those accesses is a write โ€” with no synchronization between them. Go's runtime race detector (built on ThreadSanitizer) catches this by instrumenting memory accesses at runtime.

The usual suspects:

  • A global counter incremented by multiple goroutines without a mutex
  • A map written and read concurrently โ€” maps are not goroutine-safe, full stop
  • A slice being appended to from multiple goroutines simultaneously
  • A struct field updated in one goroutine while another goroutine reads it

Step 1 โ€” Enable the Race Detector

Not running with -race yet? Turn it on. The output tells you exactly which file, which line, and which goroutine caused the race:

# Run the binary with race detection
go run -race main.go

# Run tests with race detection
go test -race ./...

# Build a race-instrumented binary
go build -race -o myapp .

Read the stack traces carefully. They show which goroutine wrote, which goroutine read, and where each was spawned. Start there.

Step 2 โ€” Fix the Race

Pick the approach that fits your situation.

Option A: sync.Mutex (most common fix)

Protect shared variables with a mutex. One goroutine holds the lock; everyone else waits.

Before (broken):

var counter int

func increment() {
    counter++ // DATA RACE: multiple goroutines hit this
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
}

After (fixed with Mutex):

import "sync"

var (
    counter int
    mu      sync.Mutex
)

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

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }
    wg.Wait()
    fmt.Println(counter) // always 1000
}

Option B: sync.RWMutex (read-heavy workloads)

Lots of reads, occasional writes? RWMutex lets multiple goroutines read at the same time โ€” only writes block everyone:

var (
    data map[string]string
    mu   sync.RWMutex
)

func read(key string) string {
    mu.RLock()
    defer mu.RUnlock()
    return data[key]
}

func write(key, value string) {
    mu.Lock()
    defer mu.Unlock()
    data[key] = value
}

Option C: sync/atomic (simple counters and flags)

Plain integer counters and boolean flags don't need a full mutex. sync/atomic is faster and avoids lock contention entirely:

import "sync/atomic"

var counter int64

func increment() {
    atomic.AddInt64(&counter, 1)
}

func getCount() int64 {
    return atomic.LoadInt64(&counter)
}

Option D: Channels (communicate instead of share)

Channels transfer data ownership between goroutines without sharing memory. No shared state, no race:

func worker(jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    go worker(jobs, results)

    jobs <- 5
    close(jobs)
    fmt.Println(<-results) // 10
}

Option E: sync.Map (concurrent map access)

Got races on a map? Drop-in replace it with sync.Map. No extra locking needed:

var m sync.Map

// Write
m.Store("key", "value")

// Read
val, ok := m.Load("key")
if ok {
    fmt.Println(val.(string))
}

Step 3 โ€” Verify the Fix

Re-run with the race detector. Zero races means you're done:

go test -race ./...
# Expected output (no race):
ok      github.com/yourorg/app    0.512s

go run -race main.go
# Should run without WARNING: DATA RACE output

Want extra confidence? Crank up concurrency and run multiple times:

# Run tests with parallelism cranked up
go test -race -count=10 -parallel=8 ./...

Clean across 10 parallel runs? You've fixed it.

Tips from the Trenches

  • Always run -race in CI. The detector adds 2โ€“20x CPU overhead and 5โ€“10x memory overhead โ€” too expensive for production, but totally fine for test runs.
  • Never copy a sync.Mutex. Passing a mutex by value silently breaks it. Always use a pointer, or embed it in a struct that's passed by pointer.
  • Channels own data transfer; mutexes own shared state. Reaching for a mutex on every single operation is a design smell โ€” step back and reconsider the goroutine structure.
  • Closures in goroutines are a classic trap. Loop variables captured by closures are a textbook race source. Pass the variable as an argument instead:
// BUG: all goroutines capture the same `i`
for i := 0; i < 5; i++ {
    go func() { fmt.Println(i) }()
}

// FIXED: pass `i` as argument
for i := 0; i < 5; i++ {
    go func(i int) { fmt.Println(i) }(i)
}

Related Error Notes