Fix

"fatal error: concurrent map read and map write"

Fix the 'concurrent map read and map write' panic by wrapping all map access with a sync.Mutex or sync.RWMutex.

The crash that saves you

You build a web service. It caches user sessions in a map. One handler updates a session timestamp. Another handler reads the session to check permissions. You run a load test. The server crashes with fatal error: concurrent map read and map write. The program doesn't return an error. It stops. The runtime detected a data race and pulled the plug.

This panic is a feature, not a bug. Go maps are optimized for speed, not thread safety. They assume a single goroutine owns the data. When multiple goroutines access the same map, the internal structure can corrupt. The runtime includes a detector that checks for concurrent access. If it sees a read and a write happening at the same time, it triggers a fatal error. A crash is easier to fix than silent data corruption that manifests as a bug three days later.

Maps are fast. Speed costs safety. You pay for safety with synchronization.

How maps break under pressure

Go maps are hash tables. They store data in buckets. When a map grows beyond a certain load factor, it resizes. Resizing allocates a new bucket array and moves entries from the old buckets to the new ones. This process takes time.

If a read operation starts while the map is resizing, the reader might see a half-moved entry. It might follow a pointer to freed memory. The result is undefined behavior. The runtime cannot guarantee correctness. The only safe outcome is to stop the program.

The runtime maintains a flag on the map structure. When a write begins, the flag flips. If a read sees the flag flipped, the runtime calls throw("concurrent map read and map write"). The process exits. This check runs in release builds. You don't need a race detector flag to catch this. The map protects itself by dying.

Goroutines are cheap. Maps are not magic. Protect shared state or the runtime will protect you by crashing.

Minimal example of the crash

This code creates a map and launches two goroutines. One writes, one reads. The scheduler interleaves them. The runtime detects the conflict and panics.

package main

import (
	"sync"
)

// CrashMap demonstrates a concurrent map access that triggers a runtime panic.
func CrashMap() {
	data := make(map[string]int)
	var wg sync.WaitGroup

	// Start a goroutine that writes to the map repeatedly.
	wg.Add(1)
	go func() {
		defer wg.Done() // Ensure WaitGroup is decremented when goroutine finishes.
		for i := 0; i < 1000; i++ {
			data["key"] = i // Write happens here.
		}
	}()

	// Start a goroutine that reads from the map repeatedly.
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0; i < 1000; i++ {
			_ = data["key"] // Read happens here.
		}
	}()

	wg.Wait() // Wait for both goroutines to finish.
}

The sync.WaitGroup ensures the main goroutine waits for the workers. Without it, the program might exit before the race occurs. The defer wg.Done() call is the standard pattern. It guarantees the counter decrements even if the goroutine panics.

The crash happens because data["key"] = i and _ = data["key"] execute concurrently. The runtime flag check fails. The program terminates.

Don't rely on timing. The race will happen eventually.

Realistic example with a mutex

The fix is synchronization. A sync.Mutex ensures only one goroutine accesses the map at a time. You lock before access and unlock after. The defer statement makes the unlock automatic.

package main

import (
	"sync"
)

// Cache holds data protected by a mutex.
type Cache struct {
	mu   sync.Mutex
	data map[string]int
}

// NewCache creates a new Cache instance.
func NewCache() *Cache {
	return &Cache{
		data: make(map[string]int),
	}
}

// Set adds a value to the cache.
func (c *Cache) Set(key string, value int) {
	c.mu.Lock()
	defer c.mu.Unlock() // Unlock happens when function returns.
	c.data[key] = value
}

// Get retrieves a value from the cache.
func (c *Cache) Get(key string) (int, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	val, ok := c.data[key]
	return val, ok
}

The receiver name c matches the type Cache. This is the Go convention. Receivers are usually one or two letters. The defer c.mu.Unlock() call ensures the lock releases even if the function panics. If you unlock manually and a panic occurs, the mutex stays locked. Other goroutines block forever.

The sync.Mutex serializes access. If one goroutine holds the lock, others wait. This prevents concurrent reads and writes. The map remains safe.

Locks are manual. Forgetting an unlock kills the program. Use defer to keep your sanity.

Pitfalls and runtime errors

Synchronization introduces new failure modes. The most common is a deadlock. If a goroutine holds a lock and tries to acquire the same lock again, it blocks forever. The runtime detects this and panics with fatal error: all goroutines are asleep - deadlock!.

// DeadlockExample shows a recursive lock that causes a deadlock.
func DeadlockExample(c *Cache) {
	c.mu.Lock()
	c.Set("key", 1) // Set tries to lock again. Deadlock.
	c.mu.Unlock()
}

The Set method locks the mutex. The caller already holds the lock. The goroutine waits for itself. The runtime detects that all goroutines are blocked. It throws a fatal error.

Another pitfall is holding the lock too long. If you perform slow work while holding a lock, other goroutines wait. This reduces throughput. Keep critical sections small. Lock, access the map, unlock. Do not call network requests or heavy computation inside the locked region.

The sync.RWMutex allows multiple readers. You use RLock for reads and Lock for writes. Readers can proceed in parallel. Writers still block everyone. This helps when reads vastly outnumber writes. If writes are frequent, readers block waiting for the write lock, and the performance gain vanishes.

Maps are shared. Shared state requires discipline. Measure contention before optimizing.

Decision matrix

Choose the right tool based on your access patterns. Each option has trade-offs.

Use a sync.Mutex when you need simple protection and writes are frequent. The overhead is low. The code is easy to reason about.

Use a sync.RWMutex when reads vastly outnumber writes and you want parallel reads. Profile first. The extra complexity isn't worth it if writes are common.

Use sync.Map when you have high contention on a map with distinct keys per goroutine. sync.Map uses internal sharding. It's optimized for cases where goroutines operate on different keys. If goroutines touch the same keys, sync.Map degrades to mutex performance.

Use a single goroutine with channels when the map is the source of truth for a pipeline. Send requests to the goroutine. It owns the map. This avoids locks entirely. It follows the Go proverb: share memory by communicating.

Use a local map when the data belongs to one goroutine and doesn't need sharing. Pass the map by value or reference within that goroutine. No synchronization needed.

Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing.

Where to go next