How to use sync.Map
You are building a cache. Goroutines are flying everywhere. One reads a value, another writes a value, a third deletes a key. You use a standard map[string]string. The program crashes with a fatal error. The race detector screams. You need a map that doesn't explode when multiple goroutines touch it at the same time.
sync.Map is one tool for this job. It is not the default choice. It has a specific shape and a specific cost. It trades compile-time type safety for read performance. Use it when the profile tells you to, not by habit.
When a mutex isn't enough
A standard Go map is fast but fragile. It assumes one goroutine at a time. If two goroutines write, or one writes while another reads, the map corrupts or panics. The compiler won't catch this. The runtime will.
The usual fix is a sync.Mutex. You lock, do the work, unlock. That works everywhere. It keeps the map safe. It also serializes every access. If you have a hundred goroutines reading, they all wait for the lock. Reads block reads. Reads block writes. The lock becomes a bottleneck.
sync.Map takes a different approach. It uses internal sharding and atomic operations to let reads happen without locks. It is optimized for a workload where reads vastly outnumber writes, and different goroutines tend to write to different keys.
Think of it like a library with separate desks for each section. If everyone checks out books from different sections, they never block each other. If everyone crowds the same desk, it slows down. sync.Map is designed for the scattered crowd.
sync.Map trades type safety for read performance. Choose the trade-off you can live with.
Minimal example
Here is the simplest usage. You declare a sync.Map, store a value, load it, and delete it. The API returns values as interface{}, so you have to type-assert.
package main
import (
"fmt"
"sync"
)
func main() {
// sync.Map handles concurrency internally. No mutex needed.
var m sync.Map
// Store takes key and value as interface{}.
m.Store("user:1", "Alice")
// Load returns the value and a boolean indicating existence.
// The value is interface{}, so assert to the concrete type.
if val, ok := m.Load("user:1"); ok {
name := val.(string)
fmt.Println(name)
}
// Delete removes the key.
m.Delete("user:1")
}
How it works under the hood
When you call Store, the map checks if the key exists. If it is a new key, it might update a "dirty" map or the "read" map depending on internal state. The magic happens in Load.
sync.Map keeps a read-only snapshot of the map. Reads hit this snapshot without any locking. If the key is in the snapshot, you get the value instantly. If the key was added or modified recently, the read falls back to a locked path to check the dirty map. This split allows high read throughput.
Under the hood, sync.Map maintains two maps. The read map is a struct containing a frozen snapshot of the data and an expunged flag. The dirty map holds recent writes. When you call Store, the map checks the read map. If the key exists and isn't expunged, it updates the dirty map. If the dirty map doesn't exist, it creates one. The read map is updated atomically only when the dirty map grows large enough. This atomic swap is what allows lock-free reads.
The trade-off is that writes are more expensive than a mutex-protected map. The API forces you to work with interface{}. You lose compile-time type safety for the values. The compiler won't catch a typo in your type assertion. If you assert val.(int) when the value is a string, the program panics at runtime.
Go 1.18 introduced generics, but sync.Map predates them. You cannot parameterize sync.Map with types. You must wrap it in a struct to regain type safety. The receiver name is usually one or two letters matching the type: (c *Cache), not (this *Cache).
Realistic example
A common pattern is a request cache. You have many requests reading the same data, and occasional updates. sync.Map fits here if the keys are spread out.
Here is a cache wrapper that restores type safety. The struct holds the map. The methods handle the assertions.
package main
import "sync"
// Cache wraps sync.Map for type-safe access.
// The underlying map stores interface{} values.
type Cache struct {
m sync.Map
}
// Get retrieves a profile by ID.
// Returns empty string if not found.
func (c *Cache) Get(id string) string {
// Load returns interface{}.
val, ok := c.m.Load(id)
if !ok {
return ""
}
// Assert to string.
// Use comma-ok idiom in production to avoid panics.
return val.(string)
}
// Set updates or adds a profile.
func (c *Cache) Set(id, profile string) {
c.m.Store(id, profile)
}
Use LoadOrStore when you want to set a value only if the key is missing. It returns the existing value or the stored value, so you don't need a separate load check. It is perfect for lazy initialization.
// GetOrSet retrieves a profile or sets it if missing.
// Returns the value and a boolean indicating if it was loaded.
func (c *Cache) GetOrSet(id, profile string) (string, bool) {
// LoadOrStore is atomic.
// It avoids race conditions where two goroutines check for a key.
actual, loaded := c.m.LoadOrStore(id, profile)
return actual.(string), loaded
}
Range iterates over all entries. It is useful for cleanup or logging. The callback receives key and value as interface{}. Return false to stop iteration early.
// Range iterates over all entries.
// The callback receives key and value as interface{}.
// Return false to stop iteration early.
func (c *Cache) PrintAll() {
c.m.Range(func(key, value interface{}) bool {
// Assert types inside the callback.
id := key.(string)
profile := value.(string)
fmt.Printf("%s: %s\n", id, profile)
return true
})
}
Pitfalls and traps
sync.Map has traps. The biggest one is the type system. You store interface{}. You load interface{}. You assert. If you assert wrong, you get a panic. The compiler won't help. You will see panic: interface conversion: interface {} is int, not string. This happens at runtime. Wrap your map or assert carefully.
Another pitfall is the Range function. It is not atomic. The map can change while you are ranging. You might see a key twice, or miss a key added during the range. Don't use Range to compute exact totals. Range is a snapshot of the iteration order, but not the data. If you delete a key during range, you might still see it. If you add a key, you might not. This is by design. Range is for best-effort traversal, not consistency.
sync.Map doesn't support len(). You can't ask how many items are in the map. You have to count them manually, which is expensive and racy. If you need the length, use a separate counter or a mutex map.
There is also a memory leak risk. sync.Map keeps deleted keys in a "dirty" map until the dirty map grows too large relative to the read map. If you delete keys faster than you add new ones, the dirty map might never clean up. The map holds onto memory longer than a mutex map would. If your workload is delete-heavy, sync.Map will bloat.
The worst bug in a sync.Map is the silent type assertion panic in production. Wrap your map or assert carefully.
Decision matrix
Use a sync.Map when reads vastly outnumber writes and different goroutines tend to write to distinct keys.
Use a sync.Map when you need to avoid locking overhead on the read path and can accept interface{} values.
Use a standard map protected by a sync.Mutex when you need compile-time type safety for values and the workload has frequent writes.
Use a standard map protected by a sync.RWMutex when you need to measure the map length, iterate atomically, or the write pattern causes excessive dirty map growth.
Use a standard map with a sync.Mutex when the map is small and the simplicity of a single lock outweighs the performance gain of sharding.
sync.Map is a specialist. Don't reach for it by default. Reach for it when the profile tells you to.