Go Race Detector: Finding and Fixing Data Races in Concurrent Code
Go's race detector is a built-in tool that instruments your program to detect concurrent access to shared memory without synchronization. It's one of the most effective dynamic analysis tools available in any language — and it ships with the standard Go toolchain.
Enabling the Race Detector
Add -race to any go command:
go test -race ./...
go run -race main.go
go build -race -o myappThe race detector uses about 5–10x more memory and runs ~2–20x slower, so it's typically used in CI and testing, not production builds.
What the Race Detector Finds
A data race occurs when two goroutines access the same variable concurrently and at least one access is a write:
// This code has a data race
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // RACE: multiple goroutines write without synchronization
}()
}
wg.Wait()
fmt.Println(counter)
}$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
main.main.func1()
/home/user/main.go:16 +0x44
Previous write at 0x00c0000b4010 by goroutine 6:
main.main.func1()
/home/user/main.go:16 +0x44
==================Writing Tests That Catch Races
Test concurrent code by exercising it from multiple goroutines simultaneously:
package counter_test
import (
"sync"
"testing"
"example.com/counter"
)
func TestCounterConcurrentIncrement(t *testing.T) {
c := counter.New()
var wg sync.WaitGroup
// Run 100 goroutines simultaneously
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Increment()
}()
}
wg.Wait()
if got := c.Value(); got != 100 {
t.Errorf("expected 100, got %d", got)
}
}Run with the race detector:
go test -race -count=5 ./...Running multiple times (-count=5) increases the chance of triggering intermittent races.
Fixing the Race with sync/atomic
package counter
import "sync/atomic"
type Counter struct {
value int64
}
func New() *Counter { return &Counter{} }
func (c *Counter) Increment() {
atomic.AddInt64(&c.value, 1)
}
func (c *Counter) Value() int64 {
return atomic.LoadInt64(&c.value)
}Fixing the Race with sync.Mutex
package counter
import "sync"
type Counter struct {
mu sync.Mutex
value int
}
func New() *Counter { return &Counter{} }
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}Testing Channel-Based Concurrency
func TestPipelineConcurrency(t *testing.T) {
input := make(chan int, 10)
output := make(chan int, 10)
// Start multiple workers
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := range input {
output <- n * 2
}
}()
}
// Send work
for i := 0; i < 100; i++ {
input <- i
}
close(input)
// Collect results
go func() {
wg.Wait()
close(output)
}()
var results []int
for n := range output {
results = append(results, n)
}
if len(results) != 100 {
t.Errorf("expected 100 results, got %d", len(results))
}
}Using t.Parallel() in Tests
Mark tests as safe to run in parallel within a test binary:
func TestConcurrentMap(t *testing.T) {
t.Parallel() // run this test in parallel with other Parallel tests
m := NewSafeMap()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
key := fmt.Sprintf("key%d", i)
go func(k string) {
defer wg.Done()
m.Set(k, 1)
}(key)
}
wg.Wait()
if m.Len() != 50 {
t.Errorf("expected 50 entries, got %d", m.Len())
}
}Stress Testing with the Race Detector
For thorough race detection, combine with testing.Short() to skip in fast runs:
func TestRaceConditionStress(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const goroutines = 500
const iterations = 1000
cache := NewCache()
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
key := fmt.Sprintf("key-%d", i%10)
if i%3 == 0 {
cache.Delete(key)
} else if i%2 == 0 {
cache.Get(key)
} else {
cache.Set(key, id)
}
}
}(g)
}
wg.Wait()
}CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests with race detector
run: go test -race -count=3 ./...Race Detector Suppressions
In rare cases you may need to suppress a known false positive:
//go:norace
func knownSafeOperation() {
// The race detector reports here, but we've verified manually this is safe
// Document WHY this suppression is correct
}Use sparingly — prefer fixing the race.
Key Takeaways
-racecosts 5–20x performance but finds bugs that testing alone misses- Run concurrent tests with high goroutine counts to maximize race exposure
- Use
-count=Nto run tests multiple times, increasing detection probability t.Parallel()enables parallel test execution and often surfaces races faster- Fix races with
sync/atomicfor counters,sync.Mutexfor complex state, or channels for coordination