Concurrency Safety of Maps
Understand why Go maps are not safe for concurrent access and how to synchronize them using sync.RWMutex, sync.Map, and other patterns.
A Go map looks simple — keys, values, fast lookups — but it carries a constraint that trips up developers who are new to concurrency. You cannot safely share one map across multiple goroutines that might write to it, or that might read and write at the same time. This page explains exactly what breaks, why that constraint exists, and how to work around it with the synchronization tools Go provides.
The Core Problem — Unsafe Concurrent Access
A Go map is not safe for concurrent use when at least one goroutine writes to it. If one goroutine writes to a map while another goroutine reads from it — even a different key — the result is undefined. The program may crash with a fatal error, produce incorrect data, or appear to work until it suddenly doesn't.
The simplest way to provoke the problem is to write to the same map from multiple goroutines without any protection:
package main
import "sync"
func main() {
m := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m["key"] = n // multiple goroutines writing concurrently
}(i)
}
wg.Wait()
}
Running this program will usually crash with a message like fatal error: concurrent map writes. The runtime detects the overlapping writes and stops execution rather than letting the internal data structure become corrupted silently. That detection is a safety net, not a guarantee — the runtime does not catch every possible race, and in earlier Go versions before 1.6 this kind of misuse could quietly corrupt the map without any crash.
Even mixing a single writer with concurrent readers is dangerous:
go func() {
m["key"] = 1
}()
go func() {
_ = m["key"] // reading while another goroutine writes
}()
The map's internal layout may shift while a read is in progress — buckets can be reorganized, memory can be reallocated — and a read that starts before a write can end up observing inconsistent state. The runtime will crash with fatal error: concurrent map read and map write when it notices, but you should never rely on the detection to find all issues.
Do Not Rely on the Runtime to Catch Every Race:
The runtime’s race detection for maps is a best-effort mechanism. It catches many common cases, but concurrent map access that does not trigger a crash can still produce wrong results, silently corrupt data, or cause hard-to-debug failures later. Always synchronize map access explicitly and use the race detector (go run -race) to verify correctness.
Why Maps Lack Built-In Synchronization
Maps are implemented as hash tables with a complex, dynamically growing internal structure. Under the hood, a map is an array of buckets, each bucket holding up to eight key-value pairs. When a bucket fills up, the runtime allocates overflow buckets linked to the original bucket. As the map grows, the runtime may allocate a larger array of buckets and rehash all keys into it, a process that happens during a write operation.
If two goroutines write at the same time, they might both try to allocate overflow buckets or trigger a rehash simultaneously. The internal pointers and counters that manage the bucket array can become inconsistent, leading to reads that see partial updates, writes that overwrite each other in broken ways, or panics from memory corruption.
Adding a lock to every map operation would make single-threaded map use slower for every Go program, even the vast majority that never share a map across goroutines. The Go team chose to keep maps lightweight and require developers to add synchronization themselves when concurrency is needed. That decision matches Go's broader philosophy: provide simple, fast primitives, and let higher-level constructs handle safety when the use case demands it.
Concurrent Reads Are Safe:
Multiple goroutines can read from the same map at the same time without synchronization, as long as no goroutine is writing to it. A map that is built once and then only read from across many goroutines is perfectly safe and does not require a mutex.
Detecting Race Conditions with the Go Race Detector
The race detector is the most reliable way to find concurrent map access problems. It instruments memory accesses and reports when two goroutines access the same memory location without synchronization and at least one of them is a write.
Step 1: Write a program that uses a map concurrently
Start with code that shares a map across goroutines, even if you think it is safe. The example below deliberately contains a race:
package main
import "sync"
func main() {
m := make(map[string]int)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
m["key"] = 42
}()
wg.Add(1)
go func() {
defer wg.Done()
_ = m["key"]
}()
wg.Wait()
}
Step 2: Run the program with the race detector
Use the -race flag when building, running, or testing:
go run -race main.go
Step 3: Interpret the output
The race detector prints a detailed report showing which goroutines accessed the map, what kind of access (read or write), and the stack traces where the accesses happened. A typical report looks like this:
==================
WARNING: DATA RACE
Write at 0x00c000098090 by goroutine 7:
runtime.mapassign_faststr()
/usr/local/go/src/runtime/map_faststr.go:202 +0x0
main.main.func1()
/path/to/main.go:12 +0x44
Previous read at 0x00c000098090 by goroutine 8:
runtime.mapaccess1_faststr()
/usr/local/go/src/runtime/map_faststr.go:12 +0x0
main.main.func2()
/path/to/main.go:17 +0x44
==================
The output tells you the exact file and line where each access happened. Fix the race by adding synchronization around map operations, then run the race detector again to confirm the warning is gone.
The Race Detector Finds Races That Actually Happen:
The race detector reports only races that occur during execution. If a race is possible but your particular test run does not trigger it, the detector will remain silent. Design concurrent test cases that exercise the map under load, and run the race detector as part of your regular test suite with go test -race ./....
Synchronization with sync.RWMutex
The most common and flexible way to make a map safe for concurrent access is to wrap it in a struct with a sync.RWMutex. A RWMutex allows multiple goroutines to hold a read lock simultaneously, but only one goroutine can hold a write lock at a time. This works well for maps that are read much more often than they are written.
package main
import (
"sync"
)
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func NewSafeMap() *SafeMap {
return &SafeMap{
m: make(map[string]int),
}
}
func (sm *SafeMap) Get(key string) (int, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
val, ok := sm.m[key]
return val, ok
}
func (sm *SafeMap) Set(key string, value int) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[key] = value
}
func (sm *SafeMap) Delete(key string) {
sm.mu.Lock()
defer sm.mu.Unlock()
delete(sm.m, key)
}
Every public method that touches the internal map acquires the appropriate lock before accessing sm.m. The Get method uses RLock to allow concurrent reads. Set and Delete use Lock to ensure exclusive access during writes.
The defer pattern keeps the unlock close to the lock so it’s hard to accidentally leave a lock held. The wrapper struct hides the map from outside code, so no caller can access the raw map without going through the locked methods.
This Pattern Covers Most Use Cases:
For the vast majority of concurrent map scenarios, a sync.RWMutex wrapped around a plain map is the correct solution. It gives you full type safety, allows you to enforce business invariants inside the methods, and is straightforward to reason about.
What Happens When You Forget to Lock a New Method
If you add a new method to SafeMap later and forget to lock the mutex, the race detector will catch it in tests — provided those tests exercise the method concurrently. A common mistake is adding a Len() method that reads the map without a read lock:
// DANGEROUS: no lock held
func (sm *SafeMap) Len() int {
return len(sm.m)
}
Even reading len(sm.m) requires a read lock because another goroutine might be writing to the map at the same time, and the internal structure of the map is being modified. The correct version acquires a read lock:
func (sm *SafeMap) Len() int {
sm.mu.RLock()
defer sm.mu.RUnlock()
return len(sm.m)
}
Every Map Access Must Be Locked:
Any operation that reads from or writes to the map — including len, iteration with range, or passing the map to a function that reads it — must happen while the appropriate lock is held. Missing one lock in one rarely-called method is enough to introduce a race that can crash the program months later under the right timing conditions.
Synchronization with sync.Map
The sync package provides a specialized concurrent map type, sync.Map. Its API is different from a normal map: you use Store, Load, LoadOrStore, Delete, and Range methods instead of direct bracket syntax. Values are typed as interface{}, so you lose compile-time type safety.
package main
import (
"fmt"
"sync"
)
func main() {
var sm sync.Map
sm.Store("key", 42)
if val, ok := sm.Load("key"); ok {
fmt.Println(val) // 42
}
sm.Range(func(key, value interface{}) bool {
fmt.Printf("%v: %v\n", key, value)
return true // continue iteration
})
sm.Delete("key")
}
sync.Map is optimized for two specific scenarios from the Go documentation:
- When the entry for a given key is written only once but read many times (a cache of stable entries).
- When multiple goroutines read, write, and overwrite entries for disjoint sets of keys — meaning different goroutines tend to work with different keys and rarely collide on the same key.
Internally, sync.Map uses a read-only map and a dirty map, with atomic operations to switch between them. This allows reads to proceed without locking in the common case when keys are stable. When writes happen, the internal structure is updated with locking only where necessary.
When Not to Use sync.Map
sync.Map is not a drop-in replacement for map[string]int in most programs. The official documentation states: "The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content."
Avoid sync.Map when you need:
- To iterate over a consistent snapshot of the map frequently —
Rangedoes not guarantee a point-in-time snapshot. - Compile-time type safety —
sync.Mapstoresinterface{}values, so every load requires a type assertion. - Frequent updates to the same keys — the internal optimization degrades when the same keys are written repeatedly.
sync.Map Is Not Faster By Default:
Benchmarks often show that a well-implemented sync.RWMutex wrapper outperforms sync.Map for workloads that do not match the two specific optimization scenarios. Always profile your actual workload before choosing sync.Map for performance reasons.
Advanced Pattern — Channel-Based Map Access
An alternative to locking is to give one goroutine exclusive ownership of the map and communicate with it through channels. This pattern models the map as a service: other goroutines send requests and receive replies.
package main
import "fmt"
type mapOp struct {
action string // "get", "set", "delete"
key string
value int
reply chan mapResult
}
type mapResult struct {
value int
found bool
}
func mapManager(m map[string]int, ops <-chan mapOp) {
for op := range ops {
switch op.action {
case "get":
v, ok := m[op.key]
op.reply <- mapResult{value: v, found: ok}
case "set":
m[op.key] = op.value
op.reply <- mapResult{}
case "delete":
delete(m, op.key)
op.reply <- mapResult{}
}
}
}
func main() {
m := make(map[string]int)
ops := make(chan mapOp)
go mapManager(m, ops)
reply := make(chan mapResult)
ops <- mapOp{action: "set", key: "answer", value: 42, reply: reply}
<-reply
ops <- mapOp{action: "get", key: "answer", reply: reply}
result := <-reply
fmt.Println(result.value) // 42
}
This approach serializes all map access through a single goroutine, so no locks are needed. It works well when you need to enforce more complex invariants — for example, updating multiple keys atomically or performing conditional writes that depend on the current state of several entries. The cost is the overhead of channel communication for every operation, which is slower than a mutex for simple read and write operations.
Channel-Based Maps Add Latency:
Every map operation involves a channel send and receive, plus a goroutine context switch. For high-throughput workloads, this pattern is usually slower than a sync.RWMutex. Use it when you need atomic multi-key operations or complex coordination that would be awkward to express with locks.
Sharded Maps for High-Concurrency Scenarios
When a single mutex becomes a contention bottleneck — many goroutines trying to write to different keys all blocked on the same lock — you can split the map into multiple independent shards, each with its own lock. Each shard is a smaller map protected by its own sync.RWMutex. The shard for a given key is chosen by hashing the key and using the hash to pick a shard index.
package main
import (
"hash/fnv"
"sync"
)
const shardCount = 32
type ShardedMap struct {
shards [shardCount]*shard
}
type shard struct {
mu sync.RWMutex
items map[string]interface{}
}
func NewShardedMap() *ShardedMap {
sm := &ShardedMap{}
for i := 0; i < shardCount; i++ {
sm.shards[i] = &shard{
items: make(map[string]interface{}),
}
}
return sm
}
func (sm *ShardedMap) getShard(key string) *shard {
h := fnv.New32()
h.Write([]byte(key))
return sm.shards[h.Sum32()%shardCount]
}
func (sm *ShardedMap) Get(key string) (interface{}, bool) {
s := sm.getShard(key)
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.items[key]
return val, ok
}
func (sm *ShardedMap) Set(key string, value interface{}) {
s := sm.getShard(key)
s.mu.Lock()
defer s.mu.Unlock()
s.items[key] = value
}
Different keys map to different shards, so goroutines writing to different keys can proceed in parallel without blocking each other. The shard count should be a power of two for efficient modulo calculation; 32 or 64 shards works well in practice. With too few shards, contention remains high. With too many shards, memory overhead grows for each shard's mutex and map.
Sharded maps do not support a consistent Range across all shards without additional coordination. Iterating over the entire map while other goroutines are writing requires either locking all shards (defeating the concurrency benefit) or accepting that the iteration sees a moving snapshot.
Sharding Is an Optimization, Not a Starting Point:
Do not reach for a sharded map until profiling shows that a single sync.RWMutex is genuinely a bottleneck. For most applications, the mutex-based wrapper described earlier is more than fast enough and far simpler to maintain.
Choosing the Right Synchronization Approach
| Approach | Best for | Trade-off |
|---|---|---|
sync.RWMutex + plain map | General use; most concurrent map needs | Simple, type-safe; single lock may contend under extreme write load |
sync.Map | Stable, read-heavy key sets; disjoint key access | Built-in; loses type safety; degrades with frequent updates to same keys |
| Channel-based map | Complex multi-key operations; atomic state transitions | Clean design with no locks; high per-operation overhead |
| Sharded map | Very high write concurrency across many keys | Reduced lock contention; more code; no consistent iteration |
Start with sync.RWMutex unless you have a measured reason to do otherwise. It is the simplest, safest, and most maintainable pattern for the majority of concurrent map use cases. When you suspect a performance problem, benchmark with the race detector enabled and profile before moving to a more complex pattern.
Race-Free Code Starts with Routine Testing:
Run go test -race ./... as part of your continuous integration pipeline. A map that works flawlessly in development can fail under production concurrency loads. The race detector is your first line of defense against data races in maps.
Summary
Go maps trade internal synchronization for speed, a decision that makes them unsuitable for concurrent access that involves writing. The runtime detects many — but not all — cases of unsafe concurrent map use and crashes with a fatal error rather than allowing memory corruption to propagate. The race detector provides a more reliable way to find these races during development.
The simplest and most widely applicable solution is to wrap a plain map with a sync.RWMutex, exposing methods that acquire read or write locks as needed. sync.Map offers a specialized alternative for specific access patterns, but it sacrifices type safety and is not a general-purpose replacement. Channel-based ownership and sharded maps provide further options when simple locking is insufficient.
With the techniques covered here, you can make any map safe for concurrent use.