Go Race Detector: Finding Data Races with -race
Go's race detector instruments memory accesses at compile time using ThreadSanitizer. Run with -race to detect concurrent reads and writes without synchronization. The detector reports the exact goroutines, memory addresses, and source lines involved. It incurs 5-20× overhead — use in tests and staging, not production.
What a data race is
A data race occurs when two goroutines concurrently access the same memory location, at least one writes, and there's no synchronization between them:
// DATA RACE: counter++ is not atomic
var counter int
func increment() {
counter++ // reads counter, increments, writes — not atomic
}
func main() {
for i := 0; i < 1000; i++ {
go increment()
}
time.Sleep(time.Second)
fmt.Println(counter) // result is unpredictable
}
The race: goroutine A reads counter=5, goroutine B reads counter=5, both increment to 6, both write 6. One increment is lost.
Run with the race detector:
go run -race main.go
go test -race ./...
go build -race -o myapp .
Race detector output
The race detector reports each race with full goroutine stack traces:
WARNING: DATA RACE
Write at 0x00c0000a2078 by goroutine 7:
main.increment()
/path/main.go:6 +0x28
main.main.func1()
/path/main.go:12 +0x1c
Previous write at 0x00c0000a2078 by goroutine 6:
main.increment()
/path/main.go:6 +0x28
main.main.func1()
/path/main.go:12 +0x1c
The output shows the memory address involved, the goroutines, the operation (read/write), and the exact source line. Each race is reported once, at the moment it's detected at runtime.
Common race patterns and fixes
Shared variable without synchronization:
// RACE
var cache map[string]string
func get(key string) string { return cache[key] }
func set(key, val string) { cache[key] = val }
// FIX: protect with mutex
var mu sync.RWMutex
var cache map[string]string
func get(key string) string {
mu.RLock(); defer mu.RUnlock()
return cache[key]
}
func set(key, val string) {
mu.Lock(); defer mu.Unlock()
cache[key] = val
}
Goroutine closure capturing loop variable:
// RACE: all goroutines share the same 'i' variable
for i := 0; i < 5; i++ {
go func() { fmt.Println(i) }() // reads i after loop may finish
}
// FIX: pass as argument
for i := 0; i < 5; i++ {
go func(n int) { fmt.Println(n) }(i)
}
Channel vs atomic for simple counters:
// FIX for simple counters: use sync/atomic
var counter int64
func increment() {
atomic.AddInt64(&counter, 1)
}
func read() int64 {
return atomic.LoadInt64(&counter)
}
The race detector only finds races that actually execute — it cannot prove a program is race-free
GotchaGo ConcurrencyThe race detector instruments every memory access at runtime and tracks happens-before relationships. It reports a race only when two conflicting accesses actually occur during a single run. A race on a rarely-executed code path (e.g., error handling, startup/shutdown) will only be detected if that path runs during the instrumented execution. High -race coverage requires tests that exercise concurrent code paths, including edge cases.
Prerequisites
- Goroutines
- sync package
- Happens-before in Go memory model
Key Points
- -race detects races that happen during that run — not all possible races. Tests with concurrent workloads are essential.
- The detector uses ThreadSanitizer (TSan) under the hood. Memory overhead: 5-8× normal. CPU overhead: 2-20× slower.
- race.Acquire and race.Release are called inside sync primitives (Mutex, Channel) to establish happens-before edges.
- Production binaries should not use -race — both the overhead and the binary size increase significantly.
How the detector works
The race detector uses ThreadSanitizer (TSan), a compile-time instrumentation library. Every memory read and write in the program is wrapped with calls to TSan:
// Original
counter++
// Instrumented (conceptually)
__tsan_read(&counter)
counter++
__tsan_write(&counter)
TSan maintains a shadow memory structure — for each 8 bytes of program memory, it stores metadata about the last read and write (goroutine ID, timestamp based on logical clock). On each access, it checks if the new access conflicts with the recorded accesses without a happens-before relationship.
Synchronization primitives (mutexes, channels, sync/atomic) call race.Acquire/race.Release to establish happens-before edges that TSan uses to determine whether accesses are properly synchronized.
📝Testing concurrent code for races
The race detector is most effective when combined with tests that genuinely exercise concurrent access:
func TestCacheRace(t *testing.T) {
c := NewCache()
var wg sync.WaitGroup
// Concurrent writes
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
c.Set(fmt.Sprintf("key%d", n), n)
}(i)
}
// Concurrent reads
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
c.Get(fmt.Sprintf("key%d", n))
}(i)
}
wg.Wait()
}
Run with go test -race -count=10 ./... — the -count=10 flag runs each test 10 times, increasing the chance of hitting race conditions that depend on timing.
-race is automatically enabled in CI at many companies. Google's internal Go style guide requires all new concurrent code to pass go test -race.
You run `go test -race ./...` and the tests pass with no races reported. Can you conclude the package has no data races?
easyThe test suite has 80% line coverage. Some concurrent code paths are only exercised under load that the unit tests don't simulate.
AYes — if -race reports no issues, the code is race-free
Incorrect.The race detector only reports races that actually occur during the instrumented run. If a data race exists on a code path that didn't execute during the test run, it won't be detected. Passing -race tests means no races were observed, not that no races exist.BNo — the race detector only detects races that actually execute during that run. Untested code paths and timing-dependent races won't appear.
Correct!The race detector is a dynamic analysis tool — it instruments actual execution, not static code analysis. A race on an error handling path that never triggers in tests, or a race that requires a specific goroutine interleaving that happens to not occur during your test run, will be invisible. Passing -race gives confidence but not proof. Strategies to improve coverage: stress tests under load, randomized concurrency tests (go-fuzz, property-based testing), and code review for concurrent access patterns.CThe test coverage percentage determines this — at 100% coverage, all races would be detected
Incorrect.Line coverage measures whether a line executed, not whether concurrent access patterns were exercised. A line can execute in single-threaded tests without triggering the concurrent access pattern that causes a race.DData races only matter in production — unit test races are not meaningful
Incorrect.Data races are undefined behavior. A race in tests indicates the same race likely exists in production code. The test environment may not expose the symptom (wrong output, crash) that production would, but the race is real and should be fixed.
Hint:The race detector is dynamic — it can only report races that actually happen during the run it's analyzing.