Skip to content
Codeloom
Go

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.

·7 min read · By Codeloom
Intermediate 10 min read

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:

  1. What: A read and a write to the same memory address.
  2. Where: The file and line number of both accesses (main.go:14).
  3. 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

  1. Run go test -race ./... in CI. Make it a blocking check.
  2. Protect shared state. Use mutex, RWMutex, atomic, or channels.
  3. Prefer channels for coordination. Share by communicating, not by sharing memory.
  4. Use atomic for simple counters and flags. It is faster than a mutex.
  5. Never start a goroutine without considering how it accesses shared data.
  6. Shadow loop variables in goroutines (or use Go 1.22+).
  7. Collect results via channels, not by appending to shared slices.

Summary

  • Enable the race detector with -race on go test, go run, and go 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 -race in 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.