How to Use the Go Race Detector (-race Flag)

Use the -race flag with go run or go test to detect data races in your Go program.

The bug that only appears under load

You write a Go service that processes incoming requests. You spin up a goroutine for each request, update a shared counter, and write the results to a slice. Locally, it works perfectly. You deploy it to staging, hit it with a load generator, and suddenly the counter prints negative numbers. Or the program crashes with a segmentation fault. The bug only appears when two goroutines touch the same memory at the exact same time. You are looking at a data race.

What a data race actually is

A data race happens when two or more goroutines access the same memory location concurrently, and at least one of those accesses is a write. Go does not prevent this at compile time. The language trusts you to synchronize your code. When you forget, the runtime gives you undefined behavior. Sometimes it works. Sometimes it corrupts memory. Sometimes it panics.

Think of a shared whiteboard in a busy office. If two people write on the same square at the exact same time, the ink smears. You cannot read what either person intended to write. The Go race detector acts like a motion sensor and camera system installed around that whiteboard. It watches every read and write operation. When it sees two goroutines touching the same byte without a lock or a channel, it stops the program and prints a detailed report.

Trust the detector over your intuition. Concurrency bugs hide in plain sight until the scheduler decides to interleave them differently.

Minimal example

Here is the simplest program that triggers the detector. It spawns two goroutines that both increment a shared integer.

package main

import "sync"

func main() {
    // Shared state with no synchronization mechanism
    var counter int
    var wg sync.WaitGroup

    // Launch two goroutines that race to modify the same variable
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                // Read, increment, and write happen without atomicity
                counter++
            }
        }()
    }

    wg.Wait()
    // The final value is unpredictable without synchronization
    println(counter)
}

Run it normally and it might print 2000. Run it five times and you might see 1987, 1994, or 2000. The result depends on how the scheduler interleaves the goroutines. Add the -race flag to see the truth.

go run -race main.go

The program stops immediately and prints a report. The report tells you exactly which line caused the conflict, which goroutine was reading, and which goroutine was writing. It also prints the full stack trace for both goroutines so you can trace the bug back to its origin.

Run the detector early. Fix the race before you add more goroutines.

How the detector instruments your code

The -race flag does not just check your code. It rewrites it. The Go compiler inserts instrumentation calls before every memory read and write. These calls talk to a shadow memory layer that runs alongside your actual program. Shadow memory is a parallel copy of your address space. It tracks the state of every byte: who last touched it, when it was touched, and whether a lock protects it.

When a goroutine reads a variable, the detector checks shadow memory. If another goroutine wrote to that same variable recently, and no mutex or channel synchronized the access, the detector flags it. The program halts and prints the race condition report.

This instrumentation comes with a cost. Your program runs slower and uses more memory. The slowdown is typically three to five times normal speed. Memory usage increases because the shadow memory layer duplicates your address space. You never run production workloads with the race detector enabled. You use it during development and in your CI pipeline to catch bugs before they reach users.

The Go compiler handles all the heavy lifting. You do not need to write custom instrumentation. Just pass the flag and let the toolchain do its job.

Realistic example

Data races rarely look like the counter example in production. They hide behind maps, slices, and structs. Maps are the most common culprit. Go maps are not safe for concurrent use. Reading and writing a map from different goroutines without a lock will trigger the race detector and often cause a runtime panic. The compiler rejects concurrent map writes with a concurrent map writes panic at runtime, but the race detector catches the setup before the panic occurs.

Here is a realistic scenario: a cache that stores user sessions. One goroutine handles incoming requests and reads the cache. Another goroutine runs in the background and evicts expired sessions.

package main

import (
    "fmt"
    "sync"
    "time"
)

// SessionCache stores user tokens with an expiration time
type SessionCache struct {
    mu       sync.Mutex // Protects the underlying map from concurrent access
    sessions map[string]time.Time
}

// NewSessionCache initializes the map and returns a ready-to-use cache
func NewSessionCache() *SessionCache {
    return &SessionCache{
        sessions: make(map[string]time.Time),
    }
}

// GetToken checks if a session exists and returns its expiration
func (c *SessionCache) GetToken(id string) (time.Time, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    // Lock acquired before map access prevents concurrent modification
    t, ok := c.sessions[id]
    return t, ok
}

// AddToken inserts a new session with a 24-hour expiration
func (c *SessionCache) AddToken(id string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    // Write happens under the same lock to avoid race conditions
    c.sessions[id] = time.Now().Add(24 * time.Hour)
}

func main() {
    cache := NewSessionCache()
    var wg sync.WaitGroup

    // Background goroutine simulates periodic cleanup
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 50; i++ {
            cache.AddToken(fmt.Sprintf("user-%d", i))
            time.Sleep(time.Millisecond)
        }
    }()

    // Main goroutine simulates concurrent reads from HTTP handlers
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 50; i++ {
            cache.GetToken(fmt.Sprintf("user-%d", i))
            time.Sleep(time.Millisecond)
        }
    }()

    wg.Wait()
}

Run this with go run -race main.go. The detector stays quiet. The mutex synchronizes every read and write. If you remove the c.mu.Lock() calls, the detector immediately flags the map access. The report points to the exact line inside GetToken and AddToken. It shows the stack trace of the background cleanup goroutine and the stack trace of the reading goroutine. You fix the bug by restoring the lock.

The Go community accepts verbose locking because it makes the synchronization boundary visible. Hiding concurrency behind magic abstractions leads to subtle bugs. The race detector rewards explicit design.

Convention note: receiver names in Go are typically one or two letters matching the type. You will see (c *SessionCache) everywhere, not (this *SessionCache) or (self *SessionCache). Keep it short. Keep it consistent.

Pitfalls and runtime behavior

The race detector is highly accurate, but it is not perfect. It can occasionally report false positives when your code interacts with C libraries through cgo. The detector cannot see inside C code, so it sometimes assumes a race exists when the C library actually handles synchronization internally. You can suppress these reports by marking the cgo call with a //go:nocheck comment, though you should verify the C code first.

The detector also struggles with unsafe pointer arithmetic. If you manually calculate memory addresses and bypass the type system, the shadow memory layer cannot track the access patterns correctly. Use unsafe sparingly and test heavily if you must use it.

When the detector prints a report, it looks like this:

==================
WARNING: DATA RACE
Read at 0x00c000016240 by goroutine 7:
  main.main()
      /path/to/main.go:15 +0x123
Previous write at 0x00c000016240 by goroutine 6:
  main.main()
      /path/to/main.go:12 +0x456
Goroutine 7 (running) created at:
  main.main()
      /path/to/main.go:10 +0x789
Goroutine 6 (finished) created at:
  main.main()
      /path/to/main.go:8 +0xabc
==================

The report gives you the memory address, the operation type, the line number, and the goroutine IDs. It also shows where each goroutine was spawned. You do not need to memorize this format. You just need to know where to look. The first stack trace is the current access. The second stack trace is the conflicting access. Match those lines to your code and add synchronization.

Running tests with the race detector is the standard practice. Add the flag to your test command to scan your entire codebase.

go test -race ./...

The command runs every test file in every package. If a test triggers a race, the test fails and prints the report. Integrate this into your CI pipeline. Make it a hard requirement for merging pull requests. The worst goroutine bug is the one that never logs.

Convention note: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Pass it through every long-lived call site. Context is plumbing. Run it through every long-lived call site.

Decision matrix

Use the -race flag during development and in CI to catch unsynchronized memory access before deployment. Use a sync.Mutex when multiple goroutines need to read and write the same struct or map and you want fine-grained control over locking. Use a sync.RWMutex when reads vastly outnumber writes and you want to allow concurrent readers. Use channels when goroutines need to pass ownership of data rather than share it. Use the conc library when you want structured concurrency patterns without managing wait groups and error channels manually. Use context.WithTimeout when you need to cancel long-running goroutines to prevent leaks. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Where to go next