Skip to content
gohacks.dev
Concurrency

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.

10 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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.

map_test.goGo
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)
				}
			}
		})
	}
}
Text
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.497s

Results

Medians of three runs, in nanoseconds per operation, on ten cores.

WorkloadMutexRWMutexsync.Map
99% reads, 1% writes, shared keys114505.5
50% reads, 50% writes, shared keys15810327
100% writes (overwrites), shared keys17818746
Churn: 50% stores of fresh keys per goroutine19611224
One goroutine, 1% writes13.814.118.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:

typed.goGo
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)
}
Text
36 true
false
85 false 85 true

The 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

  1. Start with a plain map, guarded by a mutex inside a type, and keep the lock private.
  2. If a profile shows lock contention on that map, benchmark sync.Map on your hardware and workload. On Go 1.24 and later it is likely to win under real contention.
  3. Wrap it in a generic type so the program stays typed.
  4. 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.