Go — fatal error: concurrent map writes
Handle fatal error: concurrent map writes, which the race detector may miss, understand why it is unrecoverable, and protect or shard the map correctly.
Symptoms
- fatal error: concurrent map writes followed by a goroutine dump, with the process terminating without recover taking effect
- The crash is intermittent under load and reproducible only at certain concurrency levels
- go test -race may pass while production still crashes, because the runtime check and the detector sample differently
Possible causes
- Multiple goroutines write to the same map without a mutex, and the runtime internal check trips
- A map is shared through a global variable or struct field accessed from handlers concurrently
- One goroutine writes while another only reads — still a race for maps, still fatal when flagged
- A "readonly in practice" map becomes written by a cache-warming or lazy-init path added later
Diagnosis steps
Read the dump for both parties
go run ./app 2>&1 | grep -B2 -A8 "concurrent map writes" | head -40Interpretation
The dump lists the goroutine performing the flagged write and its stack — the code owner to fix. The check flags one writer, not both, so audit all writers of that map.
Find every access path
grep -Rn "cache\[" src/ | head -20Interpretation
Map indexing syntax on a shared name enumerates every read and write site that must agree on one synchronization strategy.
Confirm with the race detector anyway
go test -race -count=10 ./...Interpretation
Even when the fatal error is the first signal, -race often surfaces the exact conflicting pair during tests, shortening the audit.
Root cause
Go maps are not safe for concurrent use. The runtime inserts checks that detect concurrent map access and call fatal error, which terminates the process and cannot be caught by recover. The check samples accesses, so violations slip through tests and surface under production concurrency.
Fix
Guard every access with a single sync.Mutex (or RWMutex when reads dominate), or replace the map with sync.Map for disjoint key sets and mostly-read workloads, or shard the map into per-shard locks for write-heavy parallelism. Then keep -race in CI for the package.
Risk note
A recover() around the crash recovers nothing — fatal error bypasses it. Do not build supervision as a substitute for synchronization.
Verify the fix
Reproduce the concurrency pattern in a stress test with -race and without, assert zero crashes across repeated runs, and keep the race-enabled suite in CI.
Symptom walkthrough
This crash differs from a panic in the most important way — there is no recovering from it:
fatal error: concurrent map writes
goroutine 87 [running]:
main.(*Cache).Set(...)
/app/src/cache.go:44
fatal error means the runtime killed the process; any recover() up the stack is ignored. The dump names one writing goroutine, but the bug belongs to all unsynchronized writers of that map. Note the detector asymmetry: go test -race may pass repeatedly while production crashes, because the runtime check and the race detector sample accesses differently — a green race run does not clear this specific pattern.
Fix in practice
One map, one lock, every access agrees:
type Cache struct {
mu sync.RWMutex
m map[string]Entry
}
func (c *Cache) Get(k string) (Entry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.m[k]
return e, ok
}
func (c *Cache) Set(k string, e Entry) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = e
}
For mostly-read caches with disjoint keys, sync.Map removes the boilerplate:
var cache sync.Map // key string, value Entry
cache.Store(k, e)
v, ok := cache.Load(k)
For write-heavy parallelism, shard: N shards each with its own mutex, chosen by hash of the key. Whatever you pick, the invariant is total — one unsynchronized access path reintroduces the fatal.
Verification
Write a stress test that hammers the map from many goroutines exactly like production does, run it both with and without -race, repeated (-count=20) to overcome sampling. Zero crashes across both modes is the bar. Then keep the race-enabled suite in CI for this package permanently: the sampling gap between the runtime check and the detector is exactly why this bug reaches production after green test runs.