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
mapwritten 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
-racein 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)
}

