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