sync Map or a Plain Map With a Mutex in Go Compared
Compare sync.Map with a plain map behind a Mutex or RWMutex in Go 1.25 under read-heavy, mixed, write-heavy and single-goroutine loads, with measured numbers.

Ask a Go programmer how to share a map between goroutines and you will hear two answers: wrap it in a mutex, or use sync.Map. For years the advice attached to the second was a warning. The documentation says sync.Map is optimised for two specific patterns and that most code should use a plain map with its own locking, and that advice was backed by benchmarks showing sync.Map losing in general use. The implementation changed in Go 1.24, which replaced its internals with a concurrent hash trie, and the old rule of thumb needs re-measuring.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64, 10 cores)
This article does the measurement on Go 1.25.6, on a ten-core machine, across four workloads, and says what the results change. It builds on mutexes and RWMutex, and it belongs to the concurrency material. The numbers are from an Apple M1 Max, and the shape matters more than the digits.
The contenders
A plain map behind a sync.Mutex is the baseline: simple, typed, and fast when only one goroutine touches it at a time. A plain map behind a sync.RWMutex allows concurrent readers. sync.Map is a concurrent map in the standard library with methods Load, Store, LoadOrStore, LoadAndDelete, Delete, Range, Swap, CompareAndSwap and Clear. Its keys and values are any, so the compiler no longer checks types, and values you store are boxed into interfaces.
The documentation names the two cases it was built for: when an entry is written once but read many times, as in caches that only grow, and when multiple goroutines read, write and overwrite entries for disjoint sets of keys. In everything else it recommends a plain map with a mutex, for type safety and for easier maintenance of invariants. The benchmark below tests those cases and several others.
The benchmark
Each case runs a parallel benchmark over 1,024 pre-populated keys with a fixed mix of loads and stores. A separate churn case has each goroutine store into its own range of fresh keys, which grows the map, and a single-goroutine case measures the uncontended cost.
package main
import (
"fmt"
"sync"
"testing"
)
const keys = 1024
type mutexMap struct {
mu sync.Mutex
m map[int]int
}
func (c *mutexMap) Load(k int) (int, bool) {
c.mu.Lock()
v, ok := c.m[k]
c.mu.Unlock()
return v, ok
}
func (c *mutexMap) Store(k, v int) { c.mu.Lock(); c.m[k] = v; c.mu.Unlock() }
type rwMap struct {
mu sync.RWMutex
m map[int]int
}
func (c *rwMap) Load(k int) (int, bool) {
c.mu.RLock()
v, ok := c.m[k]
c.mu.RUnlock()
return v, ok
}
func (c *rwMap) Store(k, v int) { c.mu.Lock(); c.m[k] = v; c.mu.Unlock() }
type store interface {
Load(int) (int, bool)
Store(int, int)
}
type syncMap struct{ m sync.Map }
func (c *syncMap) Load(k int) (int, bool) {
v, ok := c.m.Load(k)
if !ok {
return 0, false
}
return v.(int), true
}
func (c *syncMap) Store(k, v int) { c.m.Store(k, v) }
func newStores() map[string]func() store {
return map[string]func() store{
"Mutex": func() store {
s := &mutexMap{m: map[int]int{}}
for i := 0; i < keys; i++ {
s.m[i] = i
}
return s
},
"RWMutex": func() store {
s := &rwMap{m: map[int]int{}}
for i := 0; i < keys; i++ {
s.m[i] = i
}
return s
},
"sync.Map": func() store {
s := &syncMap{}
for i := 0; i < keys; i++ {
s.Store(i, i)
}
return s
},
}
}
func run(b *testing.B, mk func() store, writePct int, disjoint bool) {
s := mk()
var id int
var idMu sync.Mutex
b.RunParallel(func(pb *testing.PB) {
idMu.Lock()
id++
me := id
idMu.Unlock()
i := 0
for pb.Next() {
i++
k := (i * 7) % keys
if disjoint {
k = (me*131 + i) % keys
}
if i%100 < writePct {
s.Store(k, i)
} else {
s.Load(k)
}
}
})
}
func BenchmarkMap(b *testing.B) {
for _, mix := range []struct {
name string
w int
disjoint bool
}{{"reads99", 1, false}, {"mixed50", 50, false}, {"writes100", 100, false}} {
for _, name := range []string{"Mutex", "RWMutex", "sync.Map"} {
b.Run(fmt.Sprintf("%s/%s", mix.name, name), func(b *testing.B) { run(b, newStores()[name], mix.w, mix.disjoint) })
}
}
}
func BenchmarkChurn(b *testing.B) {
for _, name := range []string{"Mutex", "RWMutex", "sync.Map"} {
b.Run(name, func(b *testing.B) {
s := newStores()[name]()
var id int
var idMu sync.Mutex
b.RunParallel(func(pb *testing.PB) {
idMu.Lock()
id++
me := id
idMu.Unlock()
i := 0
for pb.Next() {
i++
k := me*1_000_000 + i%50_000 // each goroutine owns a key range that cycles
if i%2 == 0 {
s.Store(k, i)
} else {
s.Load(k)
}
}
})
})
}
}
func BenchmarkSingle(b *testing.B) {
for _, name := range []string{"Mutex", "RWMutex", "sync.Map"} {
b.Run(name, func(b *testing.B) {
s := newStores()[name]()
for i := 0; i < b.N; i++ {
k := (i * 7) % keys
if i%100 < 1 {
s.Store(k, i)
} else {
s.Load(k)
}
}
})
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkMap/reads99/Mutex-10 2703825 124.1 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/Mutex-10 3175554 113.9 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/Mutex-10 2898226 111.9 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/RWMutex-10 7244097 51.11 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/RWMutex-10 6878312 49.79 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/RWMutex-10 7268682 49.93 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/sync.Map-10 72900938 5.466 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/sync.Map-10 69611637 5.070 ns/op 0 B/op 0 allocs/op
BenchmarkMap/reads99/sync.Map-10 72655101 5.541 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/Mutex-10 2280410 160.0 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/Mutex-10 2233014 156.1 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/Mutex-10 2259286 158.4 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/RWMutex-10 3442040 103.4 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/RWMutex-10 3413412 111.4 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/RWMutex-10 3502305 102.8 ns/op 0 B/op 0 allocs/op
BenchmarkMap/mixed50/sync.Map-10 14476944 26.95 ns/op 31 B/op 1 allocs/op
BenchmarkMap/mixed50/sync.Map-10 12993301 26.49 ns/op 31 B/op 1 allocs/op
BenchmarkMap/mixed50/sync.Map-10 13441737 27.78 ns/op 31 B/op 1 allocs/op
BenchmarkMap/writes100/Mutex-10 2043843 177.1 ns/op 0 B/op 0 allocs/op
BenchmarkMap/writes100/Mutex-10 2031141 179.6 ns/op 0 B/op 0 allocs/op
BenchmarkMap/writes100/Mutex-10 2013418 177.9 ns/op 0 B/op 0 allocs/op
BenchmarkMap/writes100/RWMutex-10 1941702 185.6 ns/op 0 B/op 0 allocs/op
BenchmarkMap/writes100/RWMutex-10 1888754 186.9 ns/op 0 B/op 0 allocs/op
BenchmarkMap/writes100/RWMutex-10 1898078 186.8 ns/op 0 B/op 0 allocs/op
BenchmarkMap/writes100/sync.Map-10 7543710 44.59 ns/op 62 B/op 2 allocs/op
BenchmarkMap/writes100/sync.Map-10 8060979 45.92 ns/op 62 B/op 2 allocs/op
BenchmarkMap/writes100/sync.Map-10 7915806 47.77 ns/op 62 B/op 2 allocs/op
BenchmarkChurn/Mutex-10 1536496 196.3 ns/op 12 B/op 0 allocs/op
BenchmarkChurn/Mutex-10 1819981 203.5 ns/op 10 B/op 0 allocs/op
BenchmarkChurn/Mutex-10 1841740 196.3 ns/op 10 B/op 0 allocs/op
BenchmarkChurn/RWMutex-10 3014197 111.8 ns/op 6 B/op 0 allocs/op
BenchmarkChurn/RWMutex-10 3551840 97.83 ns/op 5 B/op 0 allocs/op
BenchmarkChurn/RWMutex-10 3311794 102.2 ns/op 5 B/op 0 allocs/op
BenchmarkChurn/sync.Map-10 13140498 23.53 ns/op 33 B/op 1 allocs/op
BenchmarkChurn/sync.Map-10 14138984 24.48 ns/op 33 B/op 1 allocs/op
BenchmarkChurn/sync.Map-10 13607896 24.39 ns/op 33 B/op 1 allocs/op
BenchmarkSingle/Mutex-10 25853246 13.77 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/Mutex-10 26343790 13.79 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/Mutex-10 25418192 13.82 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/RWMutex-10 26044256 14.10 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/RWMutex-10 26123792 13.94 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/RWMutex-10 26224969 14.12 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/sync.Map-10 20099192 17.37 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/sync.Map-10 20364390 18.80 ns/op 0 B/op 0 allocs/op
BenchmarkSingle/sync.Map-10 19413592 18.57 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 21.497sResults
Medians of three runs, in nanoseconds per operation, on ten cores.
| Workload | Mutex | RWMutex | sync.Map |
|---|---|---|---|
| 99% reads, 1% writes, shared keys | 114 | 50 | 5.5 |
| 50% reads, 50% writes, shared keys | 158 | 103 | 27 |
| 100% writes (overwrites), shared keys | 178 | 187 | 46 |
| Churn: 50% stores of fresh keys per goroutine | 196 | 112 | 24 |
| One goroutine, 1% writes | 13.8 | 14.1 | 18.6 |
The first four rows are contended, and sync.Map wins all of them. On reads the gap is enormous: about 5.5 ns per Load against 114 ns behind a mutex, because the trie lets readers proceed without taking any lock, while every mutex-protected read contends on one lock word. Writes are about four times faster, since writers to different parts of the trie rarely touch the same memory. Even the plain RWMutex, which is meant for this workload, is about nine times slower on reads than sync.Map here.
The last row is the caveat. With a single goroutine, sync.Map is slower than a plain map: 18.6 ns against 13.8 ns, about 30 percent. It does more work per call (hashing the key through an interface, walking the trie, no direct map access), and there is no contention for it to win back. The churn row shows the other price: a Store of a new key allocated about 33 bytes and one allocation per operation in this test, where the mutex maps allocate nothing after warm-up, so the allocation rate and the garbage collector's share rise with write volume (the garbage collector).
What this changes
The old advice is out of date for contended maps. If a profile shows a map behind a lock as a bottleneck on a many-core machine, sync.Map is now a serious option and often the first thing to try, which was not true before Go 1.24. Check which Go version you run, because on older versions the numbers differ.
The documentation's own advice still has a reason. The type safety and the invariants point stands. A sync.Map cannot enforce that keys are strings and values are *User; a typo stores the wrong type and the error appears at the assertion on read. A struct that wraps a plain map and a mutex can also protect several fields with one lock (the map and a size counter, say), which sync.Map cannot do. If you need a compound update (check, compute, store as one step), a mutex is simpler; sync.Map offers LoadOrStore and CompareAndSwap for some of those, but not arbitrary ones.
Contention is the deciding factor, and it depends on cores. On a two-core container the contended gap shrinks; on a sixty-four-core server it grows. A service that runs with GOMAXPROCS of 2 may see no benefit (GOMAXPROCS in containers). Measure on the hardware you deploy to.
For most maps, contention is not the problem. A map with a few hundred accesses a second behind a mutex costs nothing noticeable. Don't switch because of a benchmark that does not look like your service. The lock is not the bottleneck until a profile says so (mutex profile).
A typed wrapper
If you adopt sync.Map, hide the any behind a generic wrapper so the rest of the program keeps its types:
package main
import (
"fmt"
"sync"
)
// Map is a type-safe wrapper over sync.Map.
type Map[K comparable, V any] struct {
m sync.Map
}
func (m *Map[K, V]) Load(k K) (V, bool) {
v, ok := m.m.Load(k)
if !ok {
var zero V
return zero, false
}
return v.(V), true
}
func (m *Map[K, V]) Store(k K, v V) { m.m.Store(k, v) }
func (m *Map[K, V]) LoadOrStore(k K, v V) (V, bool) {
actual, loaded := m.m.LoadOrStore(k, v)
return actual.(V), loaded
}
func main() {
var users Map[string, int]
users.Store("ada", 36)
age, ok := users.Load("ada")
fmt.Println(age, ok)
_, ok = users.Load("linus")
fmt.Println(ok)
first, loaded := users.LoadOrStore("grace", 85)
second, loaded2 := users.LoadOrStore("grace", 99)
fmt.Println(first, loaded, second, loaded2)
}36 true
false
85 false 85 trueThe wrapper restores compile-time types for callers, and the assertions inside are safe because only the wrapper writes. It is the pattern most teams settle on.
Other options
If neither a mutex map nor sync.Map fits, there are structural alternatives. Sharding splits the map into N maps by hash and gives each its own lock, cutting contention without a special type (sharded maps). Copy on write keeps an immutable map behind an atomic.Pointer and replaces it on update, which gives readers no locking and suits small, rarely changed data (RWMutex shows the pattern). Ownership gives the map to one goroutine and talks to it over channels, which needs no lock and costs a channel operation per access. And a third-party concurrent map may beat all of these for a specific workload, at the cost of a dependency.
Recommendation
- Start with a plain map, guarded by a mutex inside a type, and keep the lock private.
- If a profile shows lock contention on that map, benchmark
sync.Mapon your hardware and workload. On Go 1.24 and later it is likely to win under real contention. - Wrap it in a generic type so the program stays typed.
- If the map is touched by one goroutine, use no lock at all.
The documentation of the type is in the sync.Map reference, and the change to its implementation is listed in the Go 1.24 release notes.


