Go / Gin

Go "Concurrent Map Read and Map Write" Fatal Error Crash

Go's built-in maps are explicitly not safe for concurrent use — reading and writing the same map from multiple goroutines simultaneously, without synchronization, is documented, expected-to-crash behavior, not an edge case Go tries to handle gracefully.

The Problem

The program crashes suddenly, often under load or in production but not during simpler local testing:

fatal error: concurrent map read and map write

goroutine 42 [running]:
runtime.mapaccess2(...)
    /usr/local/go/src/runtime/map.go:474
main.getCachedValue(...)
    /app/cache.go:23

This is a fatal error, not a recoverable panic — it terminates the entire program immediately, and cannot be caught with a normal recover(), which distinguishes it from most other Go runtime errors.

Two goroutines, one unprotected map Goroutine A: reads Goroutine B: writes same map

Why It Happens

Go's runtime includes a built-in detector specifically for unsynchronized concurrent map access, which crashes the program deliberately rather than allowing the map's internal data structure to become silently, unpredictably corrupted — a corrupted map could otherwise cause much harder-to-diagnose bugs later, so Go chooses a loud, immediate crash instead. Common causes:

  • A shared cache or lookup map accessed from multiple goroutines (HTTP request handlers, background workers) without any mutex or other synchronization protecting reads and writes
  • A map being written to by a background goroutine (periodically refreshing cached data, for example) while other goroutines concurrently read from it during normal request handling
  • Assuming that reads alone are always safe without synchronization, which is not true in Go specifically when any concurrent write is also happening — read-only concurrent access from multiple goroutines is safe, but the moment even one goroutine writes while others read, all of that concurrent access becomes unsafe

The Fix

Protect map access with a mutex, ensuring both reads and writes are properly synchronized:

type SafeCache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *SafeCache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.items[key]
    return val, ok
}

func (c *SafeCache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

Using sync.RWMutex rather than a plain sync.Mutex allows multiple concurrent readers when no write is in progress, which is more efficient for read-heavy workloads than a plain mutex that would serialize even simultaneous reads unnecessarily.

For simpler cases, or when you specifically want a drop-in concurrent-safe map without manually managing a mutex, Go's standard library provides sync.Map, purpose-built for concurrent access patterns:

var cache sync.Map

cache.Store("key", "value")
value, ok := cache.Load("key")

sync.Map is specifically optimized for cases with many goroutines and relatively stable keys (read-heavy, with writes to a given key being relatively rare) — for other access patterns, particularly with more balanced read/write ratios or frequently changing key sets, a regular map with an explicit mutex, as shown above, often performs better and is easier to reason about.

Audit your codebase for every place a shared map is accessed to confirm consistent synchronization everywhere it's touched — a single unprotected access site anywhere in the code, even if every other access point correctly uses the mutex, is enough to trigger this crash, since the protection only works if it's applied universally and consistently across all access to that shared map.

Still Not Working?

If you've added synchronization but the crash persists, or you want to proactively catch this class of bug before it reaches production, run your tests (or even your normal application) with Go's built-in race detector enabled, which specifically identifies unsynchronized concurrent access patterns, including but not limited to maps, often catching the exact problematic code path directly:

go run -race main.go
go test -race ./...

The race detector adds overhead and isn't meant for production use, but running your test suite (or a representative load test) with it enabled during development or in CI is one of the most effective ways to catch this entire category of concurrency bug before it ever manifests as a production crash.