Go Race Detector: Finding Data Races in Concurrent Go Code
Data races are one of the hardest bugs to find in concurrent programs. Go ships with a built-in race detector that catches them at runtime — no external tools, no setup, just a flag.
What Is a Data Race?
A data race occurs when two goroutines access the same variable concurrently, and at least one of the accesses is a write, without synchronization. The result is undefined behavior: corrupted data, crashes, or silent wrong answers.
// Race condition — two goroutines writing to counter without sync
var counter int
func increment() {
counter++ // not atomic — read, increment, write
}
func main() {
go increment()
go increment()
time.Sleep(10 * time.Millisecond)
fmt.Println(counter) // may print 1 or 2, behavior undefined
}This looks harmless but counter++ is not atomic. It compiles to a load, add, and store — a window where another goroutine can interrupt and corrupt the value.
Enabling the Race Detector
Add -race to any go command:
# Run tests with race detection
go <span class="hljs-built_in">test -race ./...
<span class="hljs-comment"># Build a binary with race detection
go build -race -o myapp .
<span class="hljs-comment"># Run directly with race detection
go run -race main.goThe race detector instruments memory accesses at compile time. At runtime, it tracks every read and write and reports when two goroutines access the same memory unsafely.
Reading a Race Report
When the detector finds a race, it prints a detailed report:
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
main.increment()
/home/user/app/main.go:8 +0x28
Previous read at 0x00c0000b4010 by goroutine 6:
main.increment()
/home/user/app/main.go:8 +0x20
Goroutine 7 (running) created at:
main.main()
/home/user/app/main.go:14 +0x44
Goroutine 6 (running) created at:
main.main()
/home/user/app/main.go:13 +0x36
==================The report tells you:
- Which memory address was accessed
- The goroutine that wrote and the goroutine that read (or both wrote)
- The file and line number for each access
- Where each goroutine was created
Writing Tests That Trigger Races
The race detector only fires when the race actually occurs at runtime. Write tests that exercise concurrent code paths:
func TestCounter_Race(t *testing.T) {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // correct
}()
}
wg.Wait()
if counter != 100 {
t.Errorf("expected 100, got %d", counter)
}
}Run it:
go test -race -count=1 -run TestCounter_Race ./...Common Race Patterns
Slice Appends
// BAD: concurrent appends to a shared slice
var results []string
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
results = append(results, fetch(u)) // RACE
}(url)
}
wg.Wait()Fix with a mutex or channel:
// GOOD: use a channel to collect results
results := make(chan string, len(urls))
for _, url := range urls {
go func(u string) {
results <- fetch(u)
}(url)
}
var all []string
for range urls {
all = append(all, <-results)
}Map Writes
Go maps are not safe for concurrent write (or concurrent read + write):
// BAD: concurrent map writes
var cache = make(map[string]string)
func store(key, val string) {
cache[key] = val // RACE if called concurrently
}Fix with sync.Map or a mutex:
// GOOD: sync.Map for concurrent access
var cache sync.Map
func store(key, val string) {
cache.Store(key, val)
}
func load(key string) (string, bool) {
val, ok := cache.Load(key)
if !ok {
return "", false
}
return val.(string), true
}Closure Variable Capture
// BAD: goroutines capturing loop variable by reference
for _, item := range items {
go func() {
process(item) // all goroutines see the same `item` — the last value
}()
}Fix by passing the variable as a parameter:
// GOOD: pass loop variable as argument
for _, item := range items {
go func(it Item) {
process(it)
}(item)
}Channel Direction Bugs
// BAD: sending on a closed channel — panic, not a data race, but often co-occurs
close(done)
done <- struct{}{} // panic: send on closed channel
// Use context for cancellation instead
ctx, cancel := context.WithCancel(context.Background())
defer cancel()Synchronization Primitives
sync.Mutex
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}sync.RWMutex
For read-heavy workloads:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // multiple readers can hold this simultaneously
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) Set(key, val string) {
c.mu.Lock() // exclusive write lock
defer c.mu.Unlock()
c.data[key] = val
}sync/atomic
For simple integer operations, sync/atomic is faster than a mutex:
import "sync/atomic"
var hits int64
func recordHit() {
atomic.AddInt64(&hits, 1)
}
func getHits() int64 {
return atomic.LoadInt64(&hits)
}Race Detector in CI
Run the race detector on every CI build, not just locally:
# GitHub Actions
- name: Test with race detector
run: go test -race -count=1 ./...The overhead is 2-20x slower execution and 5-10x higher memory usage. This is acceptable for CI. Do not ship race-detected binaries to production.
Set GOMAXPROCS higher in CI to increase the chance of races triggering:
- name: Test with race detector
env:
GOMAXPROCS: 4
run: go test -race -timeout=120s ./...What the Race Detector Cannot Find
The race detector catches actual concurrent accesses — it requires both goroutines to run. It cannot find:
- Potential races that don't happen at runtime. Write tests that exercise concurrent paths.
- Deadlocks — use Go's
go vetand the deadlock detector packagegithub.com/sasha-s/go-deadlockfor those. - Logic bugs in concurrent code that don't manifest as data races.
- Races in C code called via cgo.
Testing Concurrent Code with helpmetest
End-to-end testing of concurrent systems requires verifying behavior under load. HelpMeTest lets you write tests in plain English that run against your live application:
Scenario: concurrent order processing
Given 10 users submit orders at the same time
When all orders complete
Then all 10 orders appear in the database with distinct IDs
And no order is duplicated or missingHelpMeTest handles the concurrency and timing — you describe the expected outcome, it verifies it. Useful for catching race conditions that only appear under real concurrent load, not in unit tests.
Key Takeaways
- Run
go test -race ./...on every commit — the race detector catches bugs that unit tests miss - The detector needs races to actually happen; write tests that exercise concurrent paths with many goroutines
- Races often hide in closures capturing loop variables, unprotected maps, and concurrent slice appends
- Use
sync.Mutex,sync.RWMutex,sync/atomic, or channels to fix races — pick the simplest one that works - Run the race detector in CI with
GOMAXPROCSset high to maximize detection coverage