Fatal Error Concurrent Map Read and Map Write Fixed
Fix fatal error concurrent map read and map write in Go: why a reader alone can crash it, how iteration triggers it, and copy on write and RWMutex fixes.

This is the sibling of concurrent map writes, and it surprises people more, because the goroutine that crashes is often one that only reads. The message is:
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
fatal error: concurrent map read and map writeThe runtime found a goroutine reading the map while another one was writing it, and stopped the process. As with the write-write case, this is a throw that recover cannot catch, and the check that raises it is a best-effort guard rather than a complete detector. This page shows the crash, explains why iteration is the usual trigger, and gives two fixes that suit different workloads. It is part of the concurrency patterns cluster. Everything ran on Go 1.25.6.
The crash
One writer, one reader, one shared map:
package main
import (
"fmt"
"sync"
)
func main() {
config := map[string]int{"workers": 4}
var wg sync.WaitGroup
wg.Go(func() {
for i := range 5_000_000 {
config["version"] = i
}
})
wg.Go(func() {
total := 0
for range 5_000_000 {
total += config["workers"]
}
fmt.Println("reader finished", total)
})
wg.Wait()
}fatal error: concurrent map read and map write
goroutine 35 [running]:
internal/runtime/maps.fatal({0x100ca8337?, 0x0?})
runtime/panic.go:1046 +0x20
main.main.func2()
...The program dies with the fatal line and a dump of every goroutine. The reader only ever looked up workers, a key the writer never touches, and it still crashed. That is the point of the message: a map is one data structure, and a write to any key may rehash or move buckets that a read of any other key is walking through. Disjoint keys are not disjoint memory in a hash table. (A sync.Map is built to handle exactly the disjoint-key case, which is why the standard library has one; see the sync.Map article.)
Ranging is a read that lasts
The most common way to hit this error in a real service is iteration. A for k, v := range m reads the map for the entire duration of the loop, which might be a millisecond or a few seconds if the body does work. The runtime has a separate message for it, concurrent map iteration and map write, but in practice you meet all three variants and they have one cause. A typical shape:
func (s *Store) Report() {
for id, user := range s.users { // long read
s.send(id, user) // slow call inside the loop
}
}
func (s *Store) Add(u User) { // called from request handlers
s.users[u.ID] = u
}Each function looks reasonable alone. Together they race whenever a request arrives during a report, and the longer the loop body runs, the more likely the collision. Slow code inside the loop raises the odds.
Reading the dump
The long goroutine dump after the fatal line looks like noise, and two things in it are useful. The first goroutine listed is the one that detected the conflict. Its top frame is internal/runtime/maps.fatal, which is the runtime's own function, and the first frame below it that belongs to your code is the access that tripped the check. In the run above that was line 20 of main.go, the reader's lookup. The other half of the conflict is somewhere else in the dump: scan the remaining goroutines for frames in your own packages that touch the same map. Here goroutine 34 was in main.main.func1 at line 14, the writer's loop, which is the other half. Be careful with one thing, though. The dump shows where goroutines were at the moment of the crash, and a map has many users, so the second goroutine in the dump is a suspect, not a proof. The race detector removes the guesswork, because it prints both accesses together.
The runtime has three related messages, and they are the same bug seen from different angles:
| Message | What overlapped |
|---|---|
concurrent map writes | two writes |
concurrent map read and map write | a lookup and a write |
concurrent map iteration and map write | a range loop and a write |
The fix is the same for all three: every access to a shared map, read or write, goes through the same lock or the same owner.
Why it only happens in production
Developers commonly report this error as something that "never happens locally". A laptop test with one request at a time has no concurrency. Production has hundreds of simultaneous requests, and a collision needs a write and a read to overlap within microseconds, which is rare at low traffic and routine at high traffic. A service can run for months on a map that is written once a day, then crash at the exact moment a write lands during a busy second. That pattern, a crash that follows traffic and is hard to reproduce, is a good reason to run load tests under -race before shipping, not after.
Fix 1: RWMutex, and copy before doing slow work
A sync.RWMutex allows any number of readers at once or one writer. That matches the shape of the problem. The detail that people miss is what to do about the slow loop body: do not hold the read lock while calling out. Copy what you need under the lock, release it, then iterate the copy.
package main
import (
"fmt"
"maps"
"slices"
"sync"
)
type Store struct {
mu sync.RWMutex
users map[int]string
}
func (s *Store) Add(id int, name string) {
s.mu.Lock()
defer s.mu.Unlock()
s.users[id] = name
}
func (s *Store) Lookup(id int) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
name, ok := s.users[id]
return name, ok
}
// Snapshot copies the map under the read lock; callers iterate the copy without holding anything.
func (s *Store) Snapshot() map[int]string {
s.mu.RLock()
defer s.mu.RUnlock()
return maps.Clone(s.users)
}
func main() {
s := &Store{users: map[int]string{}}
var wg sync.WaitGroup
for g := range 4 {
wg.Go(func() {
for i := range 1000 {
s.Add(g*1000+i, "user")
}
})
}
wg.Go(func() {
for range 200 {
for id := range s.Snapshot() { // safe: this is a private copy
_ = id
}
}
})
wg.Wait()
ids := slices.Sorted(maps.Keys(s.Snapshot()))
fmt.Println("users:", len(ids), "first:", ids[0], "last:", ids[len(ids)-1])
}users: 4000 first: 0 last: 3999It finishes cleanly with 4,000 users. maps.Clone copies in a single locked pass, and the slow work happens outside it. If the snapshot is large and taken often the copy has a cost, and the next fix is better. If you can instead keep the loop body fast and hold the read lock for its whole duration, that is acceptable too, with one warning from the RWMutex documentation: a pending Lock call blocks new readers, so a reader that takes the read lock a second time while holding it (a recursive read) can deadlock when a writer arrives in between. Never take RLock twice in one call path.
Fix 2: copy on write behind an atomic pointer
For data that is read constantly and changes rarely (feature flags, routing tables, configuration), locks are the wrong tool: every reader pays for a lock acquisition just in case. The alternative is to never mutate a published map. A writer builds a new map, a copy with the change applied, and swaps the pointer. Readers load the pointer and use whatever version they got, with no locking and no chance of seeing a half-written map:
package main
import (
"fmt"
"maps"
"sync"
"sync/atomic"
)
type Flags struct {
current atomic.Pointer[map[string]bool]
mu sync.Mutex // serializes writers; readers never take it
}
func NewFlags(initial map[string]bool) *Flags {
f := &Flags{}
f.current.Store(&initial)
return f
}
func (f *Flags) Enabled(name string) bool {
return (*f.current.Load())[name] // read-only, lock-free
}
func (f *Flags) Set(name string, on bool) {
f.mu.Lock()
defer f.mu.Unlock()
next := maps.Clone(*f.current.Load())
next[name] = on
f.current.Store(&next)
}
func main() {
flags := NewFlags(map[string]bool{"search-v2": false})
var wg sync.WaitGroup
wg.Go(func() {
for i := range 1000 {
flags.Set("search-v2", i%2 == 0)
}
})
for range 4 {
wg.Go(func() {
seen := 0
for range 200_000 {
if flags.Enabled("search-v2") {
seen++
}
}
})
}
wg.Wait()
fmt.Println("final value of search-v2:", flags.Enabled("search-v2"))
}final value of search-v2: falseThe writer's loop alternates the flag a thousand times while four readers poll it two hundred thousand times each, and nothing crashes, because no map is ever written after it becomes visible. The writer mutex serializes concurrent Set calls so two writers do not both start from the same old copy and drop one change. The cost is a full copy per write, which is fine for a table of flags and a poor choice for a map with a million entries updated hundreds of times a second. The typed atomics article goes through the pattern.
The rule that keeps this safe is that the published map is frozen: nobody may write to it after Store, including the code that built it. Return a copy if you hand the map to callers who might modify it.
Choosing
Use the RWMutex version when the map changes often, when it is large, or when you need read-modify-write operations under one lock. Use copy on write when reads dominate by orders of magnitude and the map is small enough to copy. Use sync.Map for append-mostly caches. And when you are not sure which race you have, run the test suite under the race detector, which reports the read and the write with both stacks. The Go FAQ explains why built-in maps are left unsynchronized in its entry on map safety, and the sync/atomic package documents Pointer[T].


