How to find data races in Go with the race detector
Data races are the kind of bug that waste an afternoon. Two goroutines touch the same variable, one of them writes, and the program behaves differently from one run to the next. You stare at the code and it looks fine. You add a print statement and the bug disappears. You remove it and the bug comes back.
Go’s built-in race detector helps you catch these bugs while the code is running. It instruments memory accesses at compile time and watches for conflicting, unsynchronized accesses at runtime. When it finds one, it prints a report with exact file names, line numbers, and goroutine stacks, then exits with a non-zero status unless you configure it differently.
Under the hood, it uses ThreadSanitizer, a dynamic race detection library originally from Google.
How to enable the Go race detector
Add -race to any go command:
# Run tests with race detection
go test -race ./...
# Build a race-instrumented binary
go build -race -o myapp .
# Run directly
go run -race main.go
go test -race is the most common entry point, and the easiest to wire into CI. If your pipeline doesn’t already run it, that’s the single most impactful change you can make today. Many teams gate every pull request on it.
The tradeoff: race-instrumented binaries use roughly 5-10x more memory and run 2-20x slower. Don’t ship them to production permanently. Running them in staging or canary environments, though, can catch races that only appear under real traffic patterns.
Reading a race detector report
When the detector finds a race, it prints something like this:
WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 7:
main.(*Counter).Read()
/home/user/counter.go:14 +0x3c
Previous write at 0x00c0000b4010 by goroutine 6:
main.(*Counter).Increment()
/home/user/counter.go:10 +0x4c
Goroutine 7 (running) created at:
main.main()
/home/user/main.go:22 +0x118
Goroutine 6 (running) created at:
main.main()
/home/user/main.go:20 +0xd8
Three things to look at:
- What happened — a read and a write to the same address with no synchronization between them.
- Where — exact file names, line numbers, and function names for both accesses.
- Who started them — the full creation stack for each goroutine.
The goroutine creation stacks are often the most useful part. They show you which go statement launched the goroutine, which tells you why those two goroutines were running concurrently in the first place.
Reproducing timing-sensitive races
Some races only trigger under specific scheduling. A test might pass 99 out of 100 runs and fail once. Here’s how to flush them out.
Run tests many times. The -count flag repeats tests:
go test -race -count=100 ./...
Crank up GOMAXPROCS. More parallelism means different goroutine scheduling, which can surface races that hide on a single core:
GOMAXPROCS=4 go test -race -count=50 ./...
Stress the scheduler. Drop runtime.Gosched() calls in suspect code during debugging. This yields the processor and increases the chance of interleaving that triggers the race.
If a race only shows up under production-like load, build a race-enabled binary with go build -race and run it in staging. The GORACE environment variable controls behavior — GORACE="log_path=/tmp/race" writes reports to a file instead of stderr, which is easier to collect from long-running services.
Common data race patterns and how to fix them
Unprotected shared counter
The most common data race in Go. Two goroutines increment a variable with no synchronization:
package main
import (
"fmt"
"sync"
)
type Counter struct {
count int
}
func (c *Counter) Increment() {
c.count++ // DATA RACE
}
func (c *Counter) Read() int {
return c.count // DATA RACE
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Increment()
}()
}
wg.Wait()
fmt.Println(c.Read())
}
Fix with sync.Mutex:
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Read() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
For a simple counter, sync/atomic is lighter:
import "sync/atomic"
type Counter struct {
count atomic.Int64
}
func (c *Counter) Increment() {
c.count.Add(1)
}
func (c *Counter) Read() int64 {
return c.count.Load()
}
Accidental closure capture in older Go modules
This one is subtle if you maintain code that still uses the pre-Go 1.22 loop semantics. A goroutine captures a loop variable that mutates underneath it:
for _, item := range items {
go func() {
process(item) // DATA RACE: item changes on next iteration
}()
}
Fix by passing the variable as a function argument:
for _, item := range items {
go func(it string) {
process(it)
}(item)
}
Go 1.22 changed loop variable scoping so each iteration gets its own variables for modules that declare go 1.22 or later in go.mod. If your module has opted into those semantics, this specific range-loop pattern is no longer a race. The broader lesson still matters: be deliberate about what goroutines capture, especially when the captured value is reused outside the loop body.
Unsynchronized map access
Maps in Go are not safe for concurrent use. Reading and writing from multiple goroutines is a race, and Go will sometimes crash with concurrent map writes even without the race detector:
m := make(map[string]int)
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
Use sync.Mutex to wrap map access, or use sync.Map if your access pattern is read-heavy with infrequent writes.
Leaking goroutines with shared state
Goroutines that outlive their expected lifetime can create races when the data they reference gets reused or modified elsewhere. If you’re dealing with goroutine lifecycle problems, Common Goroutine Leaks in Go and How to Avoid Them covers the patterns that prevent this.
What the race detector cannot do
It only finds races that actually execute during a run. If a code path isn’t exercised, its races go undetected. This is why test coverage matters — the race detector is only as good as the code paths your tests hit. It also can’t detect deadlocks or other concurrency bugs that aren’t data races.
Architecture support is limited too. The Go documentation currently lists support for linux/amd64, linux/ppc64le, linux/arm64, linux/s390x, linux/loong64, freebsd/amd64, netbsd/amd64, darwin/amd64, darwin/arm64, and windows/amd64. The detector also requires cgo, and on non-Darwin systems you need a C compiler installed.
A practical checklist
- Add
go test -race ./...to CI. Highest-value step, lowest effort. - Run race-enabled binaries in staging when investigating production concurrency issues.
- Use
-countto stress flaky races that don’t reproduce easily. - Protect shared state with
sync.Mutex,sync.RWMutex,sync/atomic, or channels. - Prefer sending data over channels to sharing memory when the design allows it.
- Use
deferfor unlock calls so you don’t forget to release locks. More on this in How to defer in Golang like a pro.
The overhead of -race in tests is almost always worth it. Most Go code is concurrent whether you meant it to be or not — an HTTP handler, a background worker, a goroutine you spun up “just for this one thing.” The race detector is how you find out what you missed before your users do.