Fatal Error Concurrent Map Writes and How to Fix It
Why Go crashes with fatal error concurrent map writes, why recover cannot catch it, how to find the writers with the race detector and four ways to fix it.

The message looks like this, and then the process is gone:
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
fatal error: concurrent map writesThere is no panic you can recover from and no error you can return. The Go runtime detected that two goroutines were modifying the same map at the same time, decided the map's internal structure could already be corrupt, and stopped the program. It is one of the most common production crashes in Go services, and the fix is short once you know what is shared. This page shows the crash, explains why recover does not help, and walks through the fixes. It belongs to the concurrency patterns cluster, and the sibling error with a different message is concurrent map read and map write. Everything ran on Go 1.25.6.
Reproducing the crash
Two goroutines writing to one map, in a loop, is enough. The program below also wraps the work in a recover to show that it does not help:
package main
import (
"fmt"
"sync"
)
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r) // never runs
}
}()
counts := make(map[int]int)
var wg sync.WaitGroup
for g := range 4 {
wg.Go(func() {
for i := range 1_000_000 {
counts[i%1000+g] = i
}
})
}
wg.Wait()
fmt.Println("finished", len(counts))
}fatal error: concurrent map writes
goroutine 20 [running]:
internal/runtime/maps.fatal({0x1002914c5?, 0x0?})
runtime/panic.go:1046 +0x20
main.main.func2()
example.test/lab/main.go:20 +0x60
sync.(*WaitGroup).Go.func1()
sync/waitgroup.go:239 +0x4c
created by sync.(*WaitGroup).Go in goroutine 1
...The output starts with the fatal line, then a stack of the goroutine that detected the conflict, then dumps of every other goroutine. The deferred function never printed recovered. That is because the runtime calls its internal fatal function rather than panic, which skips deferred calls altogether. The distinction matters in practice: a web server's recovery middleware, which turns a handler panic into a 500, does nothing for this error. One bad write takes down the whole process and every request in flight with it (recovering from panics in handlers covers what recover can and cannot catch).
Why the runtime throws instead of corrupting silently
A Go map is a hash table with buckets, and a write may move entries between buckets during growth. If two goroutines write at once, they can both decide to grow, or one can insert while the other relocates, and the table ends up with lost entries or broken links that later cause infinite loops or memory errors. The runtime keeps a flag in the map header that a write sets for its duration. A second write that finds the flag set knows another goroutine is in the middle of a write and aborts.
That check is cheap by design and therefore incomplete. It catches writes that overlap in time, but a write that starts after another finished, with no synchronization between them, is still a data race that the check cannot see. So a program can share a map unsafely for a long time and only crash on the day two writes land in the same few nanoseconds. Treat the crash as proof of a bug that was there all along, and treat its absence as proof of nothing. The reliable detector is the race detector, which flags the first unsynchronized access on a test run.
Find the writers
The goroutine dump in the crash output shows where the detecting goroutine was, but the other writer is somewhere in the dump too. Look for frames in your own packages that touch the map. A faster route is to reproduce under -race, which prints both stacks together:
go test -race ./...Most of the time the cause falls into one of a few shapes. A package-level var cache = map[string]T{} written from request handlers. A struct with a map field and a method that writes it, used by several goroutines though it looks like a plain value. A closure that captures a map and is passed to go func(). A "registry" that is filled at runtime. In every case the map is reachable from more than one goroutine, and at least one of them writes.
Fix 1: a mutex
This is the default. Put the map and its lock together in one type, and make the type the only way in:
package main
import (
"fmt"
"sync"
)
type Counts struct {
mu sync.Mutex
m map[int]int
}
func (c *Counts) Set(k, v int) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
func (c *Counts) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.m)
}
func main() {
counts := &Counts{m: make(map[int]int)}
var wg sync.WaitGroup
for g := range 4 {
wg.Go(func() {
for i := range 1_000_000 {
counts.Set(i%1000+g, i)
}
})
}
wg.Wait()
fmt.Println("finished", counts.Len())
}finished 1003It completes and reports 1,003 keys. Wrapping the map in a type with methods is worth the extra lines, because the lock cannot be forgotten at a call site when the map is unexported. If reads vastly outnumber writes, a sync.RWMutex lets readers proceed in parallel (RWMutex explains when it actually helps). For the full toolbox, including the wrong turns, see safe concurrent slices and maps.
Fix 2: sync.Map
sync.Map is a concurrent map in the standard library, with Load, Store, LoadOrStore, Delete and Range. The documentation is blunt about when to use it: when an entry is written once and read many times, or when several goroutines work on disjoint sets of keys. For a general read-modify-write workload it is usually slower than a plain map with a mutex, and it loses type safety because it stores any. The sync.Map article measures the difference. I reach for it for caches that fill once and are read forever, and for nothing else.
Fix 3: shard the lock
When one mutex becomes the bottleneck, split the map into N maps, each with its own lock, and pick the shard by hashing the key. Contention drops roughly by N because goroutines touching different shards no longer queue. It costs more code and makes operations that need the whole map (a full iteration, a size count) awkward. Do it after a profile shows the single lock is hot, not before (sharded maps has the code and the numbers).
Fix 4: one owner
The design that removes the bug rather than guarding it: the map belongs to one goroutine, and everyone else sends requests over a channel. There is no sharing, so there is nothing to race on, and the owner can batch or reorder updates. It is the right shape when the map has behavior around it (expiry, eviction, notifications), and an over-engineered one for a counter. The actor style article shows the pattern.
Maps that never change
If a map is built once, before any goroutine starts, and only read afterwards, you do not need a lock: concurrent reads of an unchanging map are safe. The trap is "built once" that is not quite true, such as a lazy fill on first use. Initialize in init, in main before starting goroutines, or behind a sync.Once (sync.Once), and the read-only claim holds. Go 1.21's maps.Clone is handy for handing a goroutine its own copy when it needs to change something.
Keep it from coming back
Add -race to CI so the next unsynchronized map gets a report rather than a 3 a.m. crash. Prefer unexported map fields behind methods. And when reviewing, search the code for map-typed package variables and struct fields, then ask for each whether more than one goroutine can reach it. The official statement of the rule is in the Go FAQ entry on map safety, which explains why maps were left unsynchronized for performance, and the sync.Map documentation at pkg.go.dev covers its intended cases.


