How to Create a Thread-Safe Map in Go with sync.Map

Use `sync.Map` when you need a concurrent map for read-heavy workloads or when keys are short-lived, but prefer a standard `map` protected by a `sync.Mutex` for write-heavy scenarios or when you need to iterate over all keys.

The cache that panicked at 2 a.m.

You build a small HTTP service that caches API responses. The first request fetches data from an upstream server, stores it in a plain map[string][]byte, and returns it. The second request hits the cache. Everything works perfectly during local testing. You deploy to production. Traffic spikes. Two goroutines try to write to the map at the exact same millisecond. The runtime catches the data race and halts the entire process with fatal error: concurrent map read and map write. Your service goes dark.

The panic is not a bug in your logic. It is a consequence of how Go implements maps. A standard map is not designed for concurrent access. When multiple goroutines touch the same map without coordination, the internal hash table structure corrupts. Go prefers to crash loudly rather than silently return garbage data.

You reach for sync.Map because the name promises thread safety. It delivers safety, but not universally. sync.Map is a specialized data structure optimized for narrow access patterns. Using it as a drop-in replacement for every concurrent map introduces latency and memory overhead where you do not need it. Understanding when to use it, and when to reach for a mutex instead, separates casual Go developers from those who write performant concurrent systems.

Maps are not thread-safe by default. Pick the coordination strategy that matches your access pattern.

Why Go maps refuse to share

Go maps are hash tables backed by arrays of buckets. Each bucket holds a fixed number of key-value pairs. When you insert a value, the runtime calculates a hash, finds the target bucket, and updates pointers inside that bucket. If the bucket fills up, the runtime triggers a growth operation that allocates a new array and gradually moves entries over.

This design is fast for single-goroutine use. It is fragile under concurrency. Two goroutines writing to the same bucket can overwrite each other's pointers. A read happening during a growth operation can follow a dangling pointer. The runtime detects these conditions and panics to prevent undefined behavior.

Think of a plain map like a shared whiteboard in a busy office. Anyone can walk up and write. If two people write in the same spot at once, the text becomes unreadable. If someone tries to read while another person is erasing and redrawing the grid, they see half-formed letters. You need a protocol.

Go gives you two protocols for shared maps. The first is sync.Map, which splits the workload internally so readers rarely block writers. The second is a standard map wrapped in a sync.Mutex or sync.RWMutex, which serializes access explicitly. Both work. Both have trade-offs.

Goroutines are cheap. Channels are not magic. Maps are not thread-safe by default. Pick the coordination strategy that matches your access pattern.

The read-dirty split inside sync.Map

sync.Map does not use a single lock for all operations. It separates read-heavy paths from write-heavy paths using an internal read map and a dirty map. Reads that hit the read map complete without acquiring any lock. Writes update the dirty map and eventually promote it to the read map when the write count exceeds a threshold. This design keeps read latency near zero under high concurrency.

Here is the simplest concurrent cache using sync.Map:

package main

import (
	"fmt"
	"sync"
)

// RunCache demonstrates basic sync.Map operations under concurrency.
func RunCache() {
	var cache sync.Map
	var wg sync.WaitGroup

	// Spawn writers that populate the cache with session tokens.
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// Store writes to the dirty map first.
			// The runtime handles promotion automatically.
			cache.Store(fmt.Sprintf("user-%d", id), fmt.Sprintf("token-%d", id))
		}(i)
	}

	// Spawn readers that fetch tokens without blocking each other.
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// Load checks the read map first.
			// Falls back to the dirty map only on a miss.
			val, ok := cache.Load(fmt.Sprintf("user-%d", id))
			if ok {
				_ = val
			}
		}(i)
	}

	wg.Wait()
	fmt.Println("Cache synchronized.")
}

func main() {
	RunCache()
}

The Store method writes to the internal dirty map. If the dirty map exists and is not too large, the write completes quickly. If the dirty map grows past a threshold, sync.Map promotes it to the read map and clears the dirty map. The Load method checks the read map first. If the key exists there, it returns immediately without locking. If the key is missing, it acquires a read lock, checks the dirty map, and returns. This two-tier lookup is why sync.Map shines when reads vastly outnumber writes.

The runtime also tracks expunged entries. When you call Delete, the key is not immediately removed from the read map. Instead, it is marked as expunged. This avoids costly reallocation during high-frequency deletes. The key is physically removed only when the map is promoted or when Range iterates over it.

sync.Map is a specialized tool. It trades memory and write complexity for lock-free reads.

When specialization fights you

Specialization means trade-offs. sync.Map does not expose the underlying map. You cannot call len(cache). You cannot iterate with a standard for key, value := range cache loop. You must use the Range method, which passes a function that receives each key-value pair. The function runs while the map is partially locked, and iteration order is not guaranteed.

Here is a realistic scenario where sync.Map fights you:

package main

import (
	"fmt"
	"sync"
)

// CountActiveSessions iterates over a sync.Map to tally active connections.
func CountActiveSessions(cache sync.Map) int {
	count := 0
	// Range calls this function for each non-expunged entry.
	// The function must return true to continue iteration.
	cache.Range(func(key, value interface{}) bool {
		// Type assertion is mandatory because sync.Map stores interface{}.
		// The ok check prevents runtime panics on unexpected types.
		if val, ok := value.(string); ok && val != "expired" {
			count++
		}
		return true
	})
	return count
}

func main() {
	var cache sync.Map
	cache.Store("conn-1", "active")
	cache.Store("conn-2", "expired")
	cache.Store("conn-3", "active")
	fmt.Println("Active:", CountActiveSessions(cache))
}

The Range method is slower than a plain map iteration. It must acquire internal locks, skip expunged entries, and call a function pointer for every element. If your code needs to snapshot the map, filter keys, or calculate aggregate metrics, sync.Map adds measurable overhead.

Type safety is another friction point. sync.Map stores and returns interface{} values. Every read requires a type assertion. If you forget to check the assertion, the runtime panics with interface conversion: interface is nil, not string. The compiler cannot catch this at build time because the type is erased at runtime. You pay a small allocation cost for each boxed value, and you lose the compile-time guarantees that a typed map provides.

Do not reach for sync.Map because it sounds concurrent. Reach for it when your profiling shows read contention on a mutex-protected map.

Rolling your own with RWMutex

For most applications, a standard map wrapped in a sync.RWMutex is faster, simpler, and more predictable. The RWMutex allows multiple readers to hold the lock simultaneously, while writers get exclusive access. This matches the natural behavior of Go maps. You keep compile-time type safety, you get len() and native range loops, and you avoid the boxing overhead of interface{}.

Here is a thread-safe wrapper that follows Go conventions:

package main

import (
	"fmt"
	"sync"
)

// SessionStore holds a concurrent map of user sessions.
type SessionStore struct {
	mu sync.RWMutex
	m  map[string]string
}

// NewSessionStore initializes the underlying map.
func NewSessionStore() *SessionStore {
	return &SessionStore{
		m: make(map[string]string),
	}
}

// Set writes a session token under exclusive lock.
func (s *SessionStore) Set(userID, token string) {
	s.mu.Lock()
	// Defer ensures the lock releases even if an early return is added later.
	defer s.mu.Unlock()
	s.m[userID] = token
}

// Get reads a session token under shared read lock.
func (s *SessionStore) Get(userID string) (string, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	val, ok := s.m[userID]
	return val, ok
}

// Count returns the total number of stored sessions.
func (s *SessionStore) Count() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.m)
}

func main() {
	store := NewSessionStore()
	store.Set("alice", "tok-1")
	store.Set("bob", "tok-2")
	fmt.Println("Total:", store.Count())
}

The receiver name is usually one or two letters matching the type: (s *SessionStore), not (this *SessionStore) or (self *SessionStore). This keeps method signatures tight and matches the standard library style. The defer s.mu.Unlock() pattern is verbose by design. The community accepts the boilerplate because it makes the lock lifecycle explicit and prevents deadlocks if an early return is added later.

When multiple readers call Get, they all acquire the read lock and proceed in parallel. A writer calling Set blocks until all readers finish, then acquires the write lock exclusively. This is exactly what you want for a cache or session store. The mutex serializes writes but leaves reads fast.

If your workload is write-heavy, an RWMutex can actually perform worse than a plain Mutex because readers block writers and writers block readers. In that case, drop the R prefix and use sync.Mutex. The overhead of tracking read versus write state is not worth it when writes dominate.

Trust the standard library primitives. Wrap the value or change the design.

Choosing the right map strategy

Concurrency primitives are not interchangeable. Each one solves a specific coordination problem. Match the tool to the access pattern.

Use a plain map when only one goroutine touches the data. Use sync.Map when you have high read concurrency, keys are written once and read many times, or keys are short-lived and frequently deleted. Use a sync.RWMutex with a standard map when you need type safety, iteration, length checks, or a balanced mix of reads and writes. Use a sync.Mutex with a standard map when writes dominate and you want predictable serialization without read-lock overhead. Use a channel-based fan-out pattern when multiple goroutines need to process map entries sequentially rather than sharing state. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

The worst goroutine bug is the one that never logs. Profile before optimizing. Measure contention with pprof. Replace the mutex only when the data proves it is the bottleneck.

Where to go next