Warning Data Race Detected in go test and How to Fix It
Fix WARNING: DATA RACE from go test -race. Read the report, find the two accesses and the goroutine that started them, and choose a mutex, channel or atomic.

==================
WARNING: DATA RACE
Write at 0x00c000... by goroutine 8:
...
Previous write at 0x00c000... by goroutine 7:
...
==================This is the Go race detector telling you that two goroutines accessed the same memory without synchronisation, and at least one of them was a write. The test fails with testing.go: race detected during execution of test. It is an exact diagnosis: the detector saw it happen, so it is a bug, not a false alarm. The task is reading the report and choosing the right fix.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article reproduces a race on Go 1.25.6, walks through the report, and gives the fixes in order of preference. The setup of the detector in a CI pipeline is in running the race detector in CI.
A reproduction
package main
func main() {}package main
import (
"sync"
"testing"
)
func TestMapRace(t *testing.T) {
m := map[int]int{}
var wg sync.WaitGroup
for i := range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for j := range 100 {
m[i*100+j] = j
}
}()
}
wg.Wait()
t.Logf("entries: %d", len(m))
}
func TestMapFixed(t *testing.T) {
var mu sync.Mutex
m := map[int]int{}
var wg sync.WaitGroup
for i := range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for j := range 100 {
mu.Lock()
m[i*100+j] = j
mu.Unlock()
}
}()
}
wg.Wait()
t.Logf("entries: %d", len(m))
}TestMapRace starts four goroutines that each write a hundred keys into one map. TestMapFixed does the same with a mutex around the write.
F='s#/[^ ]*/(race_test\.go)#\1#; s/0x[0-9a-f]+/0xADDR/g; s/[Gg]oroutine [0-9]+/goroutine N/g; s#/opt/homebrew/Cellar/go/[0-9.]+/libexec#$GOROOT#; s/ \+0xADDR//; s/\t[0-9.]+s$//; s/ \([0-9.]+s\)//'
echo "# go test -race, the unsafe test"
go test -race -count=1 -run TestMapRace . 2>&1 | grep -vE '^\s*$' | sed -E "$F" | head -16
echo
echo "# the fixed test"
go test -race -count=1 -v -run TestMapFixed . 2>&1 | grep -E 'entries|^(---|ok)' | sed -E "$F; s#^ +[a-z_]+\.go:[0-9]+: # #"
echo
echo "# without -race, the runtime's own map check fires"
for i in 1 2 3; do go test -count=1 -run TestMapRace . 2>&1 | grep -E 'concurrent map|^ok|^FAIL' | head -1 | sed -E "$F"; done# go test -race, the unsafe test
==================
WARNING: DATA RACE
Write at 0xADDR by goroutine N:
runtime.mapaccess2_fast64()
$GOROOT/src/internal/runtime/maps/runtime_fast64_swiss.go:86
example.test/lab.TestMapRace.func1()
race_test.go:16
Previous write at 0xADDR by goroutine N:
runtime.mapaccess2_fast64()
$GOROOT/src/internal/runtime/maps/runtime_fast64_swiss.go:86
example.test/lab.TestMapRace.func1()
race_test.go:16
goroutine N (running) created at:
example.test/lab.TestMapRace()
race_test.go:13
testing.tRunner()
# the fixed test
entries: 400
--- PASS: TestMapFixed
ok example.test/lab
# without -race, the runtime's own map check fires
fatal error: concurrent map writes
fatal error: concurrent map writes
fatal error: concurrent map writesReading the report
The first part of the output has two things, and it is worth separating them. The line fatal error: concurrent map writes comes from the Go runtime itself, not the race detector. Maps carry a cheap built-in check for concurrent writes, which catches many cases, even without -race, and kills the process with no way to recover. The last block shows it firing in all three runs without the flag. A race on a map is therefore often a crash, not a silent corruption.
The block that starts with WARNING: DATA RACE is the detector's report. It has three parts.
- The access that triggered the report.
Write at 0xADDR by goroutine N:followed by a stack. The top frames here are in the runtime's map code, and the next frame that is yours israce_test.go:16, the line that writesm[i*100+j] = j. Read down the stack until you reach your own code. The address is the memory location; the same address in both stacks means the same variable. - The previous conflicting access.
Previous write at 0xADDR by goroutine M:with its own stack. It also ends at line 16: two goroutines executing the same line. In other reports this is a read and a write at different lines, such as one goroutine reading a field in a loop and another assigning it. - Where each goroutine was created.
Goroutine N (running) created at:and a stack that ends inrace_test.go:13, thego func()in the test. This tells you which code started the racing goroutines, and often where to put the fix: at the point that shares the variable.
The report is terse because it must be, but it contains everything you need: the variable's accesses, and who ran them.
Fixing it
Pick the simplest correct synchronisation. In order of preference:
1. Do not share. The best fix removes the sharing. If each goroutine can build its own result, let it, and combine them afterwards on one goroutine. In the example, each worker could fill a local map and send it to a collector, or return a slice per goroutine. No lock, no race, and it often scales better.
2. Hand over ownership with a channel. One goroutine owns the map and the others send it updates. The receive orders the operations. This is the "share memory by communicating" style, and it suits pipelines.
3. A mutex around every access, as TestMapFixed does. mu.Lock() before the write and mu.Unlock() after, with every access to the map, reads included, going through the same lock. The test passed with entries: 400: all four hundred writes landed. For a read-mostly structure, sync.RWMutex allows concurrent reads. Keep the mutex next to the data it guards, in the same struct, and document it.
4. sync/atomic for a single value. A counter or a flag: atomic.Int64, atomic.Bool. Atomics order accesses to that one value and nothing else; they are not a substitute for a lock around several fields that must change together.
5. sync.Map or sync.Once for specific patterns: a map written once and read often, or keys written by a single goroutine each; one-time initialisation. The documentation says when sync.Map is better than a map with a lock, which is a narrower set of cases than people assume.
Avoid fixing a race by sleeping, by adding a runtime.Gosched() call, or by making a variable volatile in your head. None orders anything. A race that "went away" after a change that did not synchronise is hidden, not fixed.
Races that look different
Maps are the easy case. Others you will meet:
- A struct field read while another goroutine assigns it, such as a config pointer swapped at runtime. Use
atomic.Pointer[T]to swap an immutable value, or a lock. - A slice appended to from several goroutines. Appends race on the length and the backing array. Each goroutine should own its slice, or use a lock.
- A loop variable captured by a goroutine. Fixed since Go 1.22 for modules declaring
go 1.22or later, where each iteration has its own variable. On older language versions, copy it first. - A test and the code under test. The test reads a field while the code's goroutine is still writing. The test must wait for the goroutine to finish, with a channel or
WaitGroup, before it looks. t.Logort.Errorafter the test returned. A goroutine that outlives the test and calls the testing methods races with the test's completion. Stop goroutines before the test returns.- Hidden sharing through a closure or a method value.
go obj.Method()sharesobj. Check what the method touches.
When the report is hard to read
A report that points into a library means your code passed shared data to it, and the race is in your usage: look for the first frame that is yours. A report with "failed to restore the stack" needs a longer history, set with GORACE=history_size=7. A report that appears only occasionally still means a real race; run with -count=50 to see it more often. And a race that disappears when you add logging is the usual sign of a real one, since logging changes timing.
If a test fails with a race in third-party code you cannot change, check the library's documentation for thread-safety. Many types are not safe for concurrent use by design, and the fix is a lock on your side.
What the detector cannot tell you
It reports races that happen during the run, so a clean result means none were observed on these paths. It does not find deadlocks, or races that depend on a particular input your tests never use. Combined with tests that exercise concurrency, such as the interleaving technique and the lab in the goroutines article, it is the strongest tool Go has for the problem. The report format is described in the race detector documentation, and the guarantees about ordering in the Go memory model.


