Go Race Detector: Finding and Fixing Data Races in Concurrent Code

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 myapp

The 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

  • -race costs 5–20x performance but finds bugs that testing alone misses
  • Run concurrent tests with high goroutine counts to maximize race exposure
  • Use -count=N to run tests multiple times, increasing detection probability
  • t.Parallel() enables parallel test execution and often surfaces races faster
  • Fix races with sync/atomic for counters, sync.Mutex for complex state, or channels for coordination

Read more