Go Race Detector: Finding Data Races in Concurrent Go Code

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.go

The 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 vet and the deadlock detector package github.com/sasha-s/go-deadlock for 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 missing

HelpMeTest 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 GOMAXPROCS set high to maximize detection coverage

Read more