What this usually means
A data race occurs when two or more goroutines access the same memory location concurrently, with at least one access being a write. The Go memory model guarantees nothing about the outcome: you could read stale data, get a partial write, or even crash. The root cause is almost always a missing or misplaced synchronization primitive (mutex, channel, or atomic operation). The Go race detector (enabled with -race) is the best tool to catch these, but it only proves the presence of a race when it actually happens — it cannot prove absence. Non-obvious causes include: using a map without a mutex (maps are not safe for concurrent use), sharing a slice that is being appended to (append may reallocate the backing array), passing a pointer to a loop variable to a goroutine (classic bug), or incorrectly assuming that 'close' on a channel is safe while writes are happening.
The first ten minutes — establish facts before touching code.
- 1Run tests with go test -race ./... — if any data race exists, the detector will print a full stack trace for each conflicting access.
- 2If the race is intermittent, run the test in a tight loop: for i in $(seq 1 100); do go test -race -count=1 ./... || break; done
- 3Check for the 'WARNING: DATA RACE' line in output — the race detector dumps the goroutine stacks for both the reader and writer goroutines.
- 4In production, build your binary with -race and deploy to a canary instance to catch races under real load (note: runtime overhead ~5-10x).
- 5For long-running services, enable the race detector in a staging environment with traffic mirroring to increase race probability.
- 6If the race is in a third-party dependency, use go mod vendor and apply a quick fix locally, then report upstream.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchGo test output: specifically the 'WARNING: DATA RACE' section with goroutine traces
- searchSource files at the line numbers shown in the race report — both the read and write access lines
- searchAny map variable shared across goroutines: maps are not safe for concurrent use without a mutex
- searchSlice variables that are appended from multiple goroutines: append may cause races on the slice header
- searchLoop variables captured by closure inside a goroutine: for i := range items { go func() { fmt.Println(i) }() }
- searchUse of sync.WaitGroup: calling Add and Done from different goroutines without proper sequencing
- searchGlobal or package-level variables that are modified in one goroutine and read in another without synchronization
Practical causes, not theory. These are the things you will actually find.
- warningSharing a map between goroutines without a sync.Mutex — maps are not goroutine-safe
- warningCapturing loop variable by reference: for _, v := range list { go func() { fmt.Println(v) }() } — v is shared across iterations
- warningUsing a slice concurrently: one goroutine reads while another appends, causing slice header race
- warningCalling sync.WaitGroup.Add after the Wait has already started — Add must happen before the goroutine starts
- warningAssuming reads are safe if writes are done — the Go memory model does not guarantee visibility without synchronization
- warningUsing time.Sleep to 'fix' a race — it only reduces probability, not the race itself
- warningAccessing a struct field from multiple goroutines without synchronization, even if each goroutine only reads
Concrete fix directions. Pick the one that matches your root cause.
- buildProtect shared data with a sync.Mutex: lock before write, lock before read (or use sync.RWMutex for read-heavy workloads)
- buildPass loop variable as argument to goroutine: for _, v := range list { go func(val Type) { ... }(v) }
- buildUse channels for ownership transfer: send the variable over a channel to ensure only one goroutine accesses it at a time
- buildReplace shared map with a concurrent-safe structure like sync.Map, but only if the access pattern fits (few writes, many reads)
- buildUse atomic operations for simple counters and flags: atomic.AddInt64, atomic.LoadInt64, atomic.StoreInt64
- buildRestructure code to avoid shared state entirely: use goroutines with channels to pass data, not share it
A fix you cannot prove is a guess. Close the loop.
- verifiedRun go test -race ./... after the fix — the 'WARNING: DATA RACE' line should be gone
- verifiedRun the test in a loop with -race for 100 iterations to ensure the race does not reappear
- verifiedCheck that the fix does not introduce deadlocks: run tests with -race and a timeout
- verifiedFor production fixes, deploy the fix to canary and monitor for race detector warnings in logs
- verifiedWrite a stress test that triggers the race condition deliberately (e.g., spawn 1000 goroutines) and verify it passes with -race
- verifiedUse the Go race detector's built-in verification: the detector only reports races that actually occurred, so multiple runs increase confidence
Things that make this bug worse or harder to find.
- warningAdding time.Sleep to 'fix' the race — it only makes the race less likely but still present
- warningUsing sync.Mutex but forgetting to lock in the read path — all accesses must be synchronized
- warningUsing sync.RWMutex and acquiring write lock while holding read lock — leads to deadlock
- warningCopying a sync.Mutex by value — always use pointer (e.g., *sync.Mutex) or embed as field pointer
- warningThinking that the race detector will catch every race — it only catches races that happen during execution
- warningIgnoring races in tests because they don't crash — data corruption can be silent
- warningUsing -race in production without understanding the performance impact — it can increase latency and memory usage
Production Payment Service: Silent Data Corruption Due to Unprotected Map
Timeline
- 09:15Pager alert: payment reconciliation reports show occasional mismatches (0.3% of transactions)
- 09:20Investigate logs: no errors, no panics — data seems consistent but some amounts are off by small values
- 09:45Suspect concurrency: the service processes 50k+ requests/min across 12 pods
- 10:00Enable race detector on a canary pod: go build -race; deploy to 1 pod
- 10:05Canary pod CPU spikes to 80% (vs 10% normally) but still serving
- 10:10Race detector fires: WARNING: DATA RACE — read and write on a map at payment/payment.go:178
- 10:15Examine code: a global map caching merchant rates is accessed by multiple goroutines without a mutex
- 10:20Fix: add sync.RWMutex to protect the map; lock on read and write
- 10:30Deploy fix to canary; race detector warnings disappear; CPU drops back to 10%
- 10:45Roll out to all pods; reconciliation mismatches drop to 0%
We were seeing a 0.3% mismatch in payment reconciliation — amounts off by pennies. No crashes, no errors in logs. The system was processing payments via gRPC in Go, and each pod handled thousands of concurrent goroutines. Initially I thought it was a database consistency issue, but the DB showed correct data. The mismatch was in the in-memory calculation.
I built the binary with -race and deployed to a single canary pod. The CPU jumped to 80% due to the race detector's overhead, but within minutes the detector reported a data race: concurrent read and write on a map that cached currency exchange rates. The map was being updated by one goroutine (when rates were refreshed) and read by many others (during payment calculation). No synchronization at all.
The fix was straightforward: add a sync.RWMutex to protect the map. RLock for reads, Lock for writes. After deploying the fix, the canary pod's CPU normalized and the race warnings stopped. The reconciliation mismatch rate dropped to zero. The lesson: never assume a shared map is safe; always synchronize, even if you think reads are harmless.
Root cause
Concurrent access to a global map without synchronization: one goroutine wrote to the map while multiple goroutines read it, causing corrupted reads and occasional stale data.
The fix
Added a sync.RWMutex to protect the map: RLock for read operations, Lock for write operations. Also ensured that the map was not reassigned (only mutated) to avoid race on the map header.
The lesson
Data races can silently corrupt data without crashing. Always use the race detector early in development, and never share mutable state across goroutines without explicit synchronization. For maps, use sync.Map or custom mutex protection.
The race detector is built into the Go toolchain via the C/C++ ThreadSanitizer (TSan) library. When you pass -race to go build or go test, the compiler inserts instrumentation at every memory access: it records the goroutine ID and the memory location. TSan then uses a happens-before relation to detect conflicting accesses. It reports the exact source lines and goroutine stacks for both the read and write.
The detector is not exhaustive: it only catches races that actually occur during execution. If a race condition is triggered only under specific timing, it may not be caught. That's why you should run tests with -race under heavy concurrency (e.g., large number of iterations or parallel goroutines). The overhead is about 5-10x in CPU and 2-20x in memory, so it's not suitable for production under normal load, but can be used on canary instances or stress testing.
One classic pattern is the 'loop closure' bug: for _, v := range items { go func() { fmt.Println(v) }() }. Here, all goroutines share the same variable v, which is updated in each iteration. The fix is to pass v as an argument: go func(val Type) { fmt.Println(val) }(v). Another pattern is using time.Sleep to 'wait until the race is over' — this is never correct. It may reduce the chance of the race happening, but it remains a time bomb.
Another hidden race is with slices: if you have a slice that is being appended by multiple goroutines, the slice header (pointer, length, capacity) can be read while being written. Even if you don't see a crash, the length can be inconsistent. Always use a mutex or channel to serialize access to slices that are shared. Similarly, when using sync.WaitGroup, ensure that Add is called before the goroutine starts, not inside it.
Go's sync.Map is optimized for two access patterns: write-once-read-many, or when multiple goroutines read and write disjoint sets of keys. It uses an internal double-checked locking mechanism and atomic operations. However, for most other patterns (especially when the map is updated frequently), a custom sync.RWMutex is more performant and predictable. I've seen teams use sync.Map incorrectly, leading to subtle races because they assumed it was 'always safe'.
If you must use sync.Map, remember that its Load, Store, LoadOrStore, and Delete methods are safe, but iterating over the map (Range) must not mutate the map concurrently. If you need to iterate and delete, you need to collect keys first. Always benchmark with your specific workload.
If you cannot enable -race in production due to performance constraints, you can still catch races by analyzing crash dumps and logs. A common sign is a 'fatal error: concurrent map read and map write' — this is a runtime panic when the map implementation detects a race (note: this only catches map races, not all races). Another sign is 'unexpected fault address' or 'runtime error: slice bounds out of range' that occurs under load.
You can also use runtime/pprof to collect goroutine stacks and look for multiple goroutines accessing the same global variable. But this is indirect. The best approach is to build a separate binary with -race and run it in a staging or canary environment with realistic traffic. Many production issues are reproducible in staging if you mirror traffic.
The race detector can sometimes report a race that is actually benign, for example, if you use a custom synchronization mechanism that the detector cannot understand (like a spinlock with atomic operations that are not recognized). In such cases, you can use the '//go:norace' comment on the function to suppress the warning, but this is dangerous and should be a last resort.
Another limitation: the detector does not observe memory ordering on non-x86 architectures (like ARM) as precisely. Also, it cannot detect races on 64-bit fields that are written and read as two 32-bit halves (non-atomic access). In those cases, you must use atomic.Value or atomic.Int64. Always test on the target architecture if possible.
Frequently asked questions
What is the difference between a data race and a race condition?
A data race is a specific type of race condition where two goroutines access the same memory location without synchronization, and at least one access is a write. A race condition is a broader term: it means the program's behavior depends on the timing of events (like goroutine scheduling) and can lead to incorrect results even without a data race. For example, a race condition can occur with properly synchronized code if the logic assumes a particular ordering that may not hold. The Go race detector only detects data races, not all race conditions.
Does the Go race detector work on all operating systems?
The race detector is supported on linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64. It is not supported on 32-bit platforms or on architectures like MIPS. Also, the race detector requires the host OS to support the necessary threads and address space. On Windows, you may need to increase the default stack size. Check the official Go documentation for the latest supported platforms.
Can a data race cause a crash even if the race detector doesn't report it?
Yes. The race detector only reports races that actually occur during the execution. If a race condition is triggered only under specific timing that did not happen, the detector will not report it. That's why it's important to run tests under heavy concurrency and multiple times. Additionally, some races can cause memory corruption that leads to crashes later, without any explicit race warning. Always treat the absence of race reports as 'no race detected', not 'no race exists'.
How can I fix a data race involving a channel?
Channels are safe to use from multiple goroutines by design, so a data race involving a channel often means you are accessing the channel's internal state incorrectly. For example, closing a channel while another goroutine is sending to it is a race (you should use a sync.Once or select with a default case). Also, accessing the channel variable itself (e.g., assigning a new channel to a variable) from multiple goroutines without synchronization is a race. The fix is to use a mutex to protect the channel variable, or restructure to avoid reassignment.
What should I do if a third-party library has a data race?
First, verify the race is indeed in the library and not in your code. The race detector includes the full stack trace. If it's in the library, check if there's a newer version that fixes it. If not, you can vendor the library and apply a local fix (e.g., add a mutex). Then report the issue upstream. In the meantime, you can also work around the race by avoiding the problematic function or by synchronizing access to the library's shared state from your side (if possible).