Most Go developers hit the same wall: you need a map shared across goroutines, you reach for sync.Map because it sounds like the right tool, and then someone runs a benchmark in a PR review and suddenly your "optimization" is 40% slower than just wrapping a plain map in a sync.RWMutex.
sync.Map is genuinely useful. It’s also genuinely misunderstood. The standard library docs say it’s "specialized" but barely explain what that means in practice. This article fixes that.
We’re going to look at the internal mechanics, run through the exact access patterns where each approach wins, and cover the gotchas that will bite you in production if you skip the theory.
What’s wrong with map+mutex?
Nothing, actually — for most cases. A plain map behind a sync.RWMutex is simple, explicit, and composes well with the rest of your code:
type SafeMap struct {
mu sync.RWMutex
m map[string]any
}
func (s *SafeMap) Load(key string) (any, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[key]
return v, ok
}
func (s *SafeMap) Store(key string, val any) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
}
The problem is contention. RWMutex allows concurrent reads, but the moment any goroutine wants a write lock, all readers block. On highly contested maps — especially on many-core machines — goroutines pile up waiting for the lock, and throughput craters.
sync.Map was designed to avoid that specific failure mode. But it trades one set of tradeoffs for another.
How sync.Map actually works
Under the hood, sync.Map maintains two internal maps:
read— an atomically updated pointer to areadOnlystruct. No lock needed to access it. This is the fast path.dirty— a plainmapprotected by aMutex. This is the slow path.
Every entry in read is a pointer to an entry struct. That entry holds the actual value as an atomic.Pointer, plus a sentinel state: either a normal value, nil (logically deleted), or expunged (removed from read but not yet from dirty).
On a Load:
- Read the
readmap atomically — no lock. - If the key is there and not expunged, return it. Fast path done.
- If not, acquire the mutex and check
dirty. Increment a miss counter. - If the miss counter crosses a threshold, promote
dirtytoreadand reset.
On a Store:
- If the key exists in
readand isn’t expunged, try a CAS on the value pointer. Lock-free. - Otherwise, lock, update (or create) in
dirty.
On a Delete:
- If the key is in
read, mark itnilatomically (a "soft delete"). Lock-free. - If only in
dirty, delete fromdirtyunder lock.
The key insight: reads of existing keys are genuinely lock-free. That’s the win condition. Everything else — new keys, writes to non-existing keys, deletions — still touches a mutex.
The workloads where sync.Map wins
1. Read-heavy caches with stable key sets
This is the canonical use case. You populate a map once (or rarely), then thousands of goroutines read from it. Think: in-memory config store, compiled route table, feature flag cache.
var routes sync.Map
// Called once at startup
func registerRoute(path string, handler http.Handler) {
routes.Store(path, handler)
}
// Called on every request — hot path
func dispatch(path string) http.Handler {
if v, ok := routes.Load(path); ok {
return v.(http.Handler)
}
return nil
}
Here, routes.Load is entirely lock-free. You get near-native map performance on reads with zero contention regardless of goroutine count.
With RWMutex, even read locks have overhead — incrementing/decrementing the reader count is an atomic operation that causes cache line bouncing on high core counts. On a 32-core machine with 100 goroutines hammering a read-only map, sync.Map will win by a wide margin.
2. Disjoint key sets per goroutine
Another design pattern the docs mention: multiple goroutines each write to their own subset of keys and rarely touch each other’s. Worker pools writing results keyed by worker ID, per-user session maps, shard-like patterns.
var sessions sync.Map
// Each connection handler works on its own key
func handleConnection(connID string, data []byte) {
sessions.Store(connID, processData(data))
}
func getSession(connID string) ([]byte, bool) {
v, ok := sessions.Load(connID)
if !ok {
return nil, false
}
return v.([]byte), true
}
With a mutex-protected map, every goroutine serializes through the same lock even though they’re working on completely different keys. sync.Map‘s lock-free read path and per-entry CAS for updates make the contention effectively disappear.
The workloads where map+mutex wins
Write-heavy or balanced read/write
Every new key write goes through dirty and the mutex. There’s no fast path for inserts. Worse, the promotion mechanism means that after enough misses, the runtime has to copy the entire dirty map to read, throwing allocation and GC pressure at you at unpredictable intervals.
Benchmarks for 50/50 read-write on a frequently mutated map consistently show sync.Map at 20–40% slower than sync.RWMutex. The two-map maintenance overhead is real.
// This pattern is slow with sync.Map
func updateCounter(key string, delta int64) {
// LoadOrStore + subsequent Store = two operations,
// both potentially going through the dirty path
actual, _ := counters.LoadOrStore(key, new(atomic.Int64))
actual.(*atomic.Int64).Add(delta)
}
If you’re building a hot counter or a leaderboard that updates every millisecond, stick with RWMutex or consider sync/atomic with a sharded map.
Small maps with infrequent but bursty writes
The two-map structure has a fixed overhead. For maps with fewer than a few hundred keys that mostly sit idle and then get batch-updated, the promotion cycle from dirty to read creates a worst-case spike. A plain Mutex (not even RWMutex) will outperform it because the critical section is so short.
When you need len()
sync.Map doesn’t have one. Not a performance issue, just a straight-up missing feature:
var m sync.Map
// This doesn't compile
// n := len(m)
// The only way — O(n), not atomic
count := 0
m.Range(func(k, v any) bool {
count++
return true
})
If your code needs to know the map size, you either maintain a separate atomic.Int64 counter manually or you use a mutex-protected map where len() is a one-liner under read lock.
When you need atomic read-modify-write on the value
LoadOrStore is not a compare-and-swap on the value. It stores the new value only if the key is absent, which is useful — but it doesn’t help you with "load the current value, compute something, store the result" atomically:
// THIS IS A RACE CONDITION
func increment(m *sync.Map, key string) {
v, _ := m.LoadOrStore(key, 0)
m.Store(key, v.(int)+1) // another goroutine can race between Load and Store
}
Go 1.23 added Swap and the older CompareAndSwap — but if your update logic is anything more complex than "replace with a new scalar", you need external synchronization anyway. At that point a mutex-protected map is cleaner and you don’t lose anything.
Gotchas
Promotion spikes under mixed workload. When the miss counter on read exceeds the length of dirty, the entire dirty map is atomically promoted to read. This is an O(n) allocation + copy. On a 100k-entry map under mixed load, this produces GC pressure spikes that show up as latency outliers. Profile with GODEBUG=gccheckmark=1 if you see weird p99 latency bumps.
Range is not a snapshot. The docs say Range may or may not reflect concurrent modifications during iteration. In practice, it iterates over the current read map plus any dirty entries. If a key is promoted mid-range, you might see it twice. Don’t make critical decisions based on Range results from a live, highly-contested map.
Type assertions everywhere. sync.Map uses any, so every Load requires a type assertion. With generics you can wrap it cleanly:
type TypedMap[K comparable, V any] struct {
m sync.Map
}
func (t *TypedMap[K, V]) Load(key K) (V, bool) {
v, ok := t.m.Load(key)
if !ok {
var zero V
return zero, false
}
return v.(V), true
}
func (t *TypedMap[K, V]) Store(key K, val V) {
t.m.Store(key, val)
}
Wrap it once in a generic struct and you never touch any again in the calling code.
Expunged entries and memory retention. When you delete a key, it doesn’t disappear immediately from read — it gets marked with the expunged sentinel pointer and only physically removed when dirty is next promoted. On a map that accumulates and deletes many transient keys (think per-request state), the read map can hold stale pointers longer than you expect. This is usually fine but is worth knowing if you’re debugging heap profiles.
Zero value on LoadOrStore. A common mistake:
// Wrong: v is nil if key existed with a nil value
v, loaded := m.LoadOrStore(key, defaultValue)
If someone stored a nil value for a key, LoadOrStore returns nil and loaded == true. Your code might treat that as "key not present." Always check loaded explicitly.
Quick decision guide
| Workload | Winner |
|---|---|
| Reads >> writes, stable keys | sync.Map |
| Disjoint keys per goroutine | sync.Map |
| Writes ≥ reads | sync.RWMutex map |
| Frequent key creation/deletion | sync.RWMutex map |
Need len() |
sync.RWMutex map |
| Need atomic value updates | sync.RWMutex map |
| High-throughput counters | Sharded atomic.Int64 |
A minimal benchmark to verify your specific case
Don’t trust generic benchmarks from the internet for your workload — read/write ratios and key cardinality dominate the results. Run this in your own package with realistic parameters:
package yourpkg_test
import (
"fmt"
"sync"
"testing"
)
const (
numKeys = 1000
readRatio = 9 // reads per write
)
func BenchmarkSyncMap(b *testing.B) {
var m sync.Map
// Pre-populate
for i := range numKeys {
m.Store(fmt.Sprintf("key-%d", i), i)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key-%d", i%numKeys)
if i%readRatio == 0 {
m.Store(key, i)
} else {
m.Load(key)
}
i++
}
})
}
func BenchmarkMutexMap(b *testing.B) {
var mu sync.RWMutex
m := make(map[string]int, numKeys)
for i := range numKeys {
m[fmt.Sprintf("key-%d", i)] = i
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key-%d", i%numKeys)
if i%readRatio == 0 {
mu.Lock()
m[key] = i
mu.Unlock()
} else {
mu.RLock()
_ = m[key]
mu.RUnlock()
}
i++
}
})
}
Run it with go test -bench=. -benchmem -cpu=1,4,8,16 and watch how the results diverge as CPU count increases. The real gap between the two approaches only shows up under parallelism.
The bottom line
sync.Map is not a general-purpose concurrent map. It’s a specialized structure that amortizes lock overhead by betting that most operations will hit the lock-free read path. When that bet pays off — read-heavy, stable keys, many goroutines — it’s genuinely faster and scales better than any mutex approach. When the bet is wrong — frequent writes, new keys, lots of deletes — you’re paying the two-map maintenance cost for nothing.
The standard library docs actually say this clearly, but developers routinely skip past it. If your instinct is "concurrent map, better use sync.Map," pause and check whether your access pattern actually matches the read-heavy profile. If it does, great — use it. If it doesn’t, a sync.RWMutex protecting a plain map is not a cop-out. It’s the right call.