How to Use sync.Map in Go (Thread-Safe Map)

Use sync.Map for high-concurrency, read-heavy scenarios to avoid the performance penalty of locking a standard map.

When a regular map becomes a bottleneck

You are building a service that compiles template files on demand. The first request for a template triggers a slow disk read and parsing step. Every subsequent request should grab the compiled version from memory. Goroutines flood in. One writes the compiled template to a map. Ten others read it simultaneously. You reach for a standard map[string]interface{} and wrap it in a sync.Mutex. It works. Then you add a second endpoint that reads configuration from the same map. The mutex becomes a bottleneck. Goroutines queue up behind each other just to read data that never changes. You need a data structure that lets readers run at full speed while writers handle the heavy lifting safely.

How sync.Map splits the work

sync.Map solves this by splitting the work between two internal maps. Think of a restaurant kitchen. The pass window is the public counter where finished dishes sit. Customers grab plates instantly without asking anyone. The kitchen itself is the back room. When a new order comes in, the chef works in the back room. When the back room gets too crowded, the chef quietly moves the most popular dishes to the pass window during a quiet moment. Readers check the pass window first. If the dish is there, they take it and leave. No coordination needed. Writers always work in the back room. The system only coordinates when the pass window needs restocking.

This design makes reads lock-free and extremely fast. Writes take a lock, but they only block other writers. The trade-off is that sync.Map does not support standard map operations like len() or direct iteration. It exposes a specific set of methods tuned for concurrent access. The sync package follows a strict naming convention: types that provide synchronization primitives are capitalized, and their methods match the operation they perform. You will see Store, Load, Delete, and Range. No hidden sugar. No implicit iteration. The API forces you to acknowledge concurrency.

Minimal example

Here is the basic interface for storing, loading, and removing values.

package main

import (
	"fmt"
	"sync"
)

func main() {
	// sync.Map is ready to use immediately. No make() required.
	var m sync.Map

	// Store writes a key-value pair. It handles concurrent writes safely.
	m.Store("config", "production")

	// Load returns the value and a boolean indicating existence.
	// The ok idiom matches standard Go map behavior.
	if val, ok := m.Load("config"); ok {
		fmt.Println(val)
	}

	// Delete removes the key. Subsequent loads return false.
	m.Delete("config")
}

The ok boolean is your only reliable indicator of presence. Always check it. Go convention treats the second return value as the source of truth for map lookups. Ignoring it leads to silent bugs when zero values collide with missing keys.

What happens under the hood

When you call Load, the runtime checks the internal read map first. This map is updated atomically, so multiple goroutines can inspect it simultaneously without acquiring a lock. If the key exists there, the value returns immediately. If the key is missing, the runtime takes a read lock and checks the dirty map. The dirty map holds recently written keys that have not yet been promoted. If the key lives in the dirty map, the runtime returns it.

Every time a read misses the read map, an internal counter increments. When that counter crosses a threshold, the runtime acquires a write lock and copies all entries from the dirty map into the read map. The dirty map resets. This promotion happens automatically in the background. You never call a Flush or Sync method. The structure manages its own amortized cost.

Store always writes to the dirty map and bumps the miss counter. Delete marks the key as tombstoned in the read map or removes it from the dirty map. The tombstone prevents stale reads while allowing future writes to the same key to proceed without blocking. The entire lifecycle runs without you managing mutexes or worrying about race conditions.

Trust the promotion threshold. Do not try to force it. The runtime calculates the optimal copy frequency based on your access pattern.

Realistic example: caching without race conditions

Caching is the most common use case. LoadOrStore eliminates the double-check pattern that plagues mutex-protected maps. You ask the map for a value. If it exists, you get it back. If it does not, you provide a fallback value, the map stores it, and returns the original fallback. This happens atomically.

Here is a template compiler that caches results across concurrent requests.

package main

import (
	"fmt"
	"sync"
)

var cache sync.Map

// compileTemplate simulates a slow disk read and parsing step.
func compileTemplate(name string) string {
	return fmt.Sprintf("compiled_%s", name)
}

// GetOrCompileTemplate fetches from cache or computes and stores it.
func GetOrCompileTemplate(name string) string {
	// LoadOrStore returns the existing value if present.
	// If absent, it stores the provided value and returns it.
	// This prevents two goroutines from compiling the same template.
	if val, loaded := cache.LoadOrStore(name, nil); loaded {
		return val.(string)
	}

	// Only the first goroutine reaches this line.
	// It computes the value and stores it for future requests.
	compiled := compileTemplate(name)
	cache.Store(name, compiled)
	return compiled
}

The Range method replaces direct iteration. It accepts a callback function that runs for each key-value pair. The callback receives a boolean parameter. Returning false stops iteration early. Returning true continues. This design avoids allocating a slice of keys while the map mutates underneath.

// PrintCacheStats demonstrates safe iteration with Range.
func PrintCacheStats() {
	count := 0
	// Range calls the callback for each key-value pair.
	// The callback runs concurrently with other map operations.
	cache.Range(func(key, value any) bool {
		count++
		// Returning true continues iteration.
		// Returning false would abort early.
		return true
	})
	fmt.Println("Cached items:", count)
}

The Range callback does not guarantee a consistent snapshot. Other goroutines can modify the map while your callback runs. You might see a key twice, or miss a key that was added mid-iteration. This is by design. Iterating over a concurrent structure inherently races with mutations. If you need a frozen copy, collect the keys into a slice first, then process them sequentially.

Pitfalls and compiler boundaries

sync.Map deliberately omits standard map syntax. You cannot use len(m) or range m. The compiler rejects len(m) with invalid operation: len(m) (operator len not defined on sync.Map). You must use Range and count manually if you need the size. Trying to iterate with for k, v := range m triggers invalid operation: range m (range of sync.Map). The language forces you to use the provided API.

Write-heavy workloads perform worse with sync.Map than with a regular map protected by sync.Mutex. The internal promotion logic and tombstone management add overhead. If your application writes as often as it reads, the mutex approach wins on raw throughput. sync.Map shines when reads outnumber writes by at least ten to one, or when keys are partitioned across goroutines.

Another trap involves zero values. Load returns the zero value of the type when a key is missing. If you store 0 or "" intentionally, Load returns (0, false) or ("", false). The boolean flag is your only reliable indicator of presence. Always check the second return value. Type assertions on any values can panic if the stored type does not match. Use type switches or explicit checks before casting.

The worst concurrent bug is the one that silently returns stale data. Verify your access pattern matches the structure.

Decision: when to use this vs alternatives

Use a regular map with sync.Mutex when your workload has frequent writes or you need standard map operations like len() and direct iteration. Use sync.Map when reads heavily outnumber writes and you want lock-free access for the hot path. Use atomic.Value when you only need to store a single value that gets replaced entirely rather than updated per key. Use a dedicated caching library like ristretto or bigcache when you need eviction policies, size limits, or metrics beyond basic concurrency safety.

Where to go next