Race Detector and Debugging Concurrent Go Code
Use Go's race detector to find and fix data races, plus tools and patterns for debugging concurrent programs effectively.
What you'll learn
- ✓How to use the Go race detector to find data races
- ✓How to read and interpret race detector output
- ✓Patterns and tools for debugging concurrent Go programs
Prerequisites
- •Basic Go knowledge
- •Understanding of goroutines and shared state
Data races are among the hardest bugs to find. They are non-deterministic, rarely reproduce under debuggers, and often cause symptoms far from the actual bug. Go’s built-in race detector catches them automatically. This guide shows you how to use it and how to fix the races it finds.
What Is a Data Race?
A data race occurs when two goroutines access the same variable concurrently, and at least one of them writes to it. The result is undefined behavior.
// This program has a data race
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
}()
}
wg.Wait()
fmt.Println(counter) // might print 987, 1000, or anything else
}
The counter++ operation is not atomic. It reads the value, increments it, and writes it back. Two goroutines can read the same value, both increment it, and both write back the same result, losing one increment.
Using the Race Detector
Enable It with -race
Add the -race flag to any Go command.
// Run with race detection
go run -race main.go
// Test with race detection
go test -race ./...
// Build with race detection
go build -race -o myapp .
Reading Race Detector Output
When a race is detected, you get output like this:
==================
WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 7:
main.main.func1()
/home/user/app/main.go:14 +0x3a
Previous write at 0x00c0000b4010 by goroutine 6:
main.main.func1()
/home/user/app/main.go:14 +0x50
Goroutine 7 (running) created at:
main.main()
/home/user/app/main.go:12 +0x84
Goroutine 6 (finished) created at:
main.main()
/home/user/app/main.go:12 +0x84
==================
The output tells you:
- What: A read and a write to the same memory address.
- Where: The file and line number of both accesses (
main.go:14). - Who: Which goroutines are involved and where they were created (
main.go:12).
Common Data Races and Fixes
Race 1: Shared Counter
Problem:
var count int
go func() { count++ }()
go func() { count++ }()
Fix with sync.Mutex:
var (
mu sync.Mutex
count int
)
go func() {
mu.Lock()
count++
mu.Unlock()
}()
go func() {
mu.Lock()
count++
mu.Unlock()
}()
Fix with sync/atomic:
var count atomic.Int64
go func() { count.Add(1) }()
go func() { count.Add(1) }()
fmt.Println(count.Load())
Use atomic for simple counters and flags. Use Mutex when you need to protect multiple variables or complex operations.
Race 2: Map Access
Problem:
m := make(map[string]int)
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }() // race: concurrent map writes
go func() { fmt.Println(m["a"]) }() // race: concurrent read and write
Fix with sync.RWMutex:
var mu sync.RWMutex
m := make(map[string]int)
go func() {
mu.Lock()
m["a"] = 1
mu.Unlock()
}()
go func() {
mu.RLock()
fmt.Println(m["a"])
mu.RUnlock()
}()
Race 3: Slice Append
Problem:
var results []int
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results = append(results, n) // race: slice header is shared
}(i)
}
Fix: collect via channel:
ch := make(chan int, 10)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
ch <- n
}(i)
}
go func() {
wg.Wait()
close(ch)
}()
var results []int
for v := range ch {
results = append(results, v) // single goroutine appends
}
Race 4: Loop Variable Capture (Pre-Go 1.22)
Problem:
for _, item := range items {
go func() {
process(item) // race: all goroutines share the same loop variable
}()
}
Fix (works in all Go versions):
for _, item := range items {
item := item // shadow the loop variable
go func() {
process(item) // each goroutine has its own copy
}()
}
Note: Go 1.22+ changed loop variable semantics so each iteration gets its own variable. But the shadow pattern is still safe and explicit.
Race 5: Struct Field Access
Problem:
type Config struct {
Debug bool
Timeout time.Duration
}
var cfg Config
go func() { cfg.Debug = true }()
go func() { fmt.Println(cfg.Timeout) }() // race on struct even if different fields
Actually, accessing different fields of a struct is safe. The race detector reports a race only if the same field (or overlapping memory) is accessed. But be careful: if the struct is small enough to fit in a register, the compiler might access the whole struct at once.
Fix: protect the whole struct:
type Config struct {
mu sync.RWMutex
debug bool
timeout time.Duration
}
func (c *Config) SetDebug(v bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.debug = v
}
func (c *Config) Timeout() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
return c.timeout
}
Race Detection in Tests
Always run your test suite with -race in CI.
# In your CI pipeline
go test -race -count=1 ./...
The -count=1 flag disables test caching, ensuring tests actually run.
Writing Race-Prone Tests
Some races only appear under contention. Write tests that exercise concurrent access.
func TestCacheIsSafe(t *testing.T) {
cache := NewCache()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func(n int) {
defer wg.Done()
cache.Set(fmt.Sprintf("key-%d", n), n)
}(i)
go func(n int) {
defer wg.Done()
cache.Get(fmt.Sprintf("key-%d", n))
}(i)
}
wg.Wait()
}
If Cache has a race, go test -race will catch it.
Debugging Tools Beyond the Race Detector
GODEBUG Environment Variable
# Detect more goroutine issues
GODEBUG=asyncpreemptoff=1 go run -race main.go
Goroutine Dump
When your program hangs (deadlock), send SIGQUIT to get a goroutine dump.
# On Unix
kill -QUIT <pid>
# Or add this to your program for an HTTP endpoint
import _ "net/http/pprof"
go func() {
http.ListenAndServe(":6060", nil)
}()
Then visit http://localhost:6060/debug/pprof/goroutine?debug=2 for a full goroutine stack dump.
Detecting Deadlocks
Go’s runtime detects simple deadlocks (all goroutines are asleep) and panics with:
fatal error: all goroutines are asleep - deadlock!
For more complex deadlocks, use the goroutine dump or a tool like go-deadlock:
import "github.com/sasha-s/go-deadlock"
// Replace sync.Mutex with deadlock.Mutex during development
var mu deadlock.Mutex
It detects potential deadlocks by tracking lock ordering.
Performance Impact
The race detector adds overhead:
- Memory: 5-10x increase
- CPU: 2-20x slowdown
Do not run with -race in production. Use it in:
- Local development
- CI test pipelines
- Staging environments (if performance allows)
Checklist for Race-Free Code
- Run
go test -race ./...in CI. Make it a blocking check. - Protect shared state. Use mutex, RWMutex, atomic, or channels.
- Prefer channels for coordination. Share by communicating, not by sharing memory.
- Use
atomicfor simple counters and flags. It is faster than a mutex. - Never start a goroutine without considering how it accesses shared data.
- Shadow loop variables in goroutines (or use Go 1.22+).
- Collect results via channels, not by appending to shared slices.
Summary
- Enable the race detector with
-raceongo test,go run, andgo build. - Read race output to identify the two conflicting accesses and the goroutines involved.
- Fix races with
sync.Mutex,sync.RWMutex,sync/atomic, or channels. - Run
-racein CI on every commit. Never skip it. - Use goroutine dumps and pprof to debug deadlocks and hangs.
- The race detector has runtime overhead, so use it in development and testing, not production.
Related articles
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Channel and Select Statement Patterns
Master Go channels and select statements with patterns for fan-out, fan-in, timeouts, cancellation, and pipeline composition.
- Go Go Sync Primitives: Mutex, RWMutex, WaitGroup, and Once
A practical guide to Go sync primitives including Mutex, RWMutex, WaitGroup, Once, and when to choose each over channels.
- Go Go Concurrency Patterns
Master Go concurrency — goroutines, channels, select, WaitGroups, mutexes, and common patterns like fan-out/fan-in and worker pools.