Tracing & Performance

Go Race Detection: Finding Concurrency Bugs

Go race detector -race flag concurrent testing: causes, fixes, and how to catch it in production with LightTrace error tracking and traces.

Data races are invisible killers in concurrent Go applications. Two or more goroutines reading and writing the same variable without synchronization can corrupt state, cause panics, or produce non-deterministic bugs that vanish the moment you add logging to debug them. The Go race detector—enabled via the -race flag during testing and development—catches these conflicts before they reach production. By running go test -race or go run -race in your CI pipeline, you get ThreadSanitizer reports that pinpoint exactly which goroutines accessed which variable unsafely, giving you a head start on writing truly concurrent code.

The Go race detector isn't a silver bullet; it's a development and testing tool with real trade-offs. It adds 2–20× runtime overhead, making production deployment impossible. But in CI and local testing, it's one of the cheapest ways to eliminate a whole class of subtle, high-impact bugs. This guide walks you through enabling it, interpreting its output, fixing the issues it finds, and integrating it into your test pipeline.

What Is a Data Race?

A data race occurs when two or more goroutines access the same memory location concurrently, and at least one of them is writing. Go's memory model guarantees safe behavior only when access to shared variables is synchronized—via channels, mutexes, or atomic operations. Without synchronization, the Go runtime makes no promise about which goroutine's write will be visible or in what order.

var counter int

go func() {
  for i := 0; i < 1000; i++ {
    counter++ // write
  }
}()

go func() {
  for i := 0; i < 1000; i++ {
    counter++ // concurrent write
  }
}()

time.Sleep(100 * time.Millisecond)
fmt.Println(counter) // might be 1000, might be 500, might crash

Even though the intention is clear—increment a shared counter—the lack of synchronization means the final value is undefined. The CPU might interleave the reads and writes; a ++ operation is not atomic. This is a data race, and it's the kind of bug that surfaces unpredictably in production, wasting hours of debugging.

Enabling the Go Race Detector

The -race flag instruments your binary to track every memory access. You can enable it in tests, short-lived commands, and ad-hoc runs:

In tests:

go test -race ./...

Running a single binary with race detection:

go run -race ./cmd/myserver

Building a race-instrumented binary (for local testing only):

go build -race -o myserver ./cmd/myserver

In CI:

# Example: GitHub Actions
- name: Run tests with race detector
  run: go test -race ./...

The race detector only works on linux/amd64, linux/ppc64le, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64. If you develop on a different architecture, you may need to test on a compatible system or use a Docker container in CI to guarantee coverage.

The race detector is enabled at compile time. Every goroutine, every memory access, and every synchronization operation gets instrumented. This has significant overhead, which is why it's unsuitable for production.

Reading ThreadSanitizer Reports

When the race detector finds a conflict, it outputs a report showing the goroutine stack traces, the memory address, and a timeline of the accesses:

==================
WARNING: DATA RACE
Write at 0x00c0001a2340 by goroutine 20:
    main.incrementCounter()
        /home/user/main.go:15 +0x44
    main.main.func2()
        /home/user/main.go:27 +0x28

Previous read at 0x00c0001a2340 by goroutine 19:
    main.printCounter()
        /home/user/main.go:21 +0x44
    main.main.func1()
        /home/user/main.go:25 +0x28

Goroutine 20 (running) created at:
    main.main()
        /home/user/main.go:26 +0x0xc0

Goroutine 19 (running) created at:
    main.main()
        /home/user/main.go:24 +0xc0
==================

The report tells you:

  • The operation and address: A write at 0x00c0001a2340 (the memory location of your variable).
  • The stack traces: Exactly where in your code each goroutine was when it accessed the variable.
  • The timeline: Which access happened first (the "Previous" read or write).

This pinpoint accuracy is invaluable. Instead of guessing, you can see the exact line and function where the unsynchronized access occurs.

Common Patterns and Fixes

Unsafe struct field access:

// BAD: Data race
type User struct {
  Name  string
  Count int
}

user := &User{}
go func() {
  user.Name = "Alice" // write
}()
go func() {
  fmt.Println(user.Name) // read
}()

GOOD: Protect with a mutex:

type User struct {
  mu    sync.Mutex
  Name  string
  Count int
}

user := &User{}
go func() {
  user.mu.Lock()
  defer user.mu.Unlock()
  user.Name = "Alice"
}()
go func() {
  user.mu.Lock()
  defer user.mu.Unlock()
  fmt.Println(user.Name)
}()

Sharing a slice or map across goroutines without locking is equally dangerous. The solution is always the same: protect shared data with a sync.Mutex, use channels to pass ownership, or use sync.atomic for primitive types.

Channels are often safer than mutexes for Go code. Instead of protecting a shared variable, send the value over a channel and let the receiving goroutine own it. This aligns with the Go idiom: "Do not communicate by sharing memory; share memory by communicating."

Integrating Race Detection into CI

Make the race detector a mandatory check in your CI pipeline. If -race finds any issues, fail the build:

# GitHub Actions example
name: Test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v4
        with:
          go-version: '1.22'
      - name: Test with race detector
        run: go test -race ./...

Running -race in CI catches bugs early, before they cause production issues. Pair this with distributed tracing in your live service so that if a race-related bug slips through, you can correlate logs and spans to diagnose the failure. Understanding what application performance monitoring reveals about your running code also helps you spot the symptoms of races—sudden goroutine count spikes, memory leaks, or hangs—in production.

Performance Trade-offs

The race detector adds significant overhead:

  • 2–10× slowdown for typical workloads.
  • Up to 20× slowdown for heavily concurrent code.
  • Memory overhead: Each goroutine uses more memory to track its accesses.

This overhead makes it unsuitable for production. A service running under race detection is so slow that it cannot serve real traffic. However, the overhead is acceptable in:

  • Local development and manual testing.
  • CI pipelines running nightly or per-commit test suites.
  • Staging environments with controlled load.

If you need to understand concurrency bottlenecks and reduce API latency in a production setting, use application performance monitoring and profiling tools instead. The race detector is for correctness in development; APM is for observability in production.

Never deploy a race-instrumented binary to production. The overhead will throttle your application, and the memory usage will balloon. Use -race only in tests, CI, and local development.

When False Positives Occur

Rarely, the race detector reports a race that isn't actually a race. This typically happens when:

  • You are using unsafe to bypass Go's memory model.
  • External C code (via cgo) accesses the same memory without proper synchronization.
  • You have intentional lock-free code that relies on memory barriers the race detector doesn't recognize.

In these edge cases, you can suppress false positives by running the race detector selectively or by using the runtime/race package to annotate safe-by-design sections. But most of the time, if the race detector flags it, it's a real bug.


Concurrent Go code is hard to get right, but the race detector makes it dramatically easier. By running go test -race in every test pass and in CI, you catch a massive class of bugs before they reach production. The overhead is real—2–20× slowdown—but it's paid during development and testing when you have time to think carefully about fixes. In production, you shift to monitoring tools and observability to understand how your code actually behaves under load. Together, the race detector and observability form a complete strategy for writing and running reliable concurrent systems.

Start tracking errors in minutes

Start catching concurrency bugs before they hit production. Run go test -race in your next test suite, fix what it finds, and integrate it into your CI pipeline. When you're ready to ship, monitor your live service with LightTrace to catch performance and reliability issues in the wild. Start free today.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.