Fix Common Errors

Fix Concurrent Map Write Errors in Go

Concurrent map writes crash Go services hard. Master sync.Mutex, sync.Map, and sharding to fix Go concurrent map writes fatal error race conditions.

When you see fatal error: concurrent map iteration and map write in Go, you've hit one of the language's most dreaded runtime panics. It means multiple goroutines are touching the same map without synchronization—one reading, another writing, or multiple writers. The error happens because Go maps are not thread-safe by design: their internal bucket structure changes during writes, and concurrent access corrupts it. Unlike some languages that silently allow this, Go crashes hard and fast. A Go concurrent map writes fatal error race condition forces you to synchronize access, but the fix depends on your access pattern.

This is high-frustration terrain. The panic might be reproducible only under load, buried in a multi-goroutine handler, or triggered by rare timing in a distributed system. Yet the fix is straightforward once you understand the trade-offs. Let's walk through why maps fail, three solid synchronization patterns, and how to catch these bugs before production sees them.

What triggers the fatal error

The concurrent map iteration and map write panic fires when the Go runtime detects overlapping reads and writes on the same map. This isn't a segfault hidden in kernel space—it's an intentional panic. Go's map implementation tracks whether a map is currently being iterated (via range or iteration internals), and if a write happens during iteration, the runtime panics immediately.

The panic can also fire when two goroutines try to write simultaneously, or one writes while another ranges over the map. Here's a minimal example:

package main

import (
	"fmt"
	"sync"
)

func main() {
	m := make(map[string]int)
	var wg sync.WaitGroup

	// Goroutine 1: writes
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0; i < 1000; i++ {
			m[fmt.Sprintf("key%d", i)] = i
		}
	}()

	// Goroutine 2: reads/iterates
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0; i < 1000; i++ {
			_ = m[fmt.Sprintf("key%d", i)]
		}
	}()

	wg.Wait()
}

Run this and it panics within milliseconds. The iteration in Goroutine 2 doesn't need to be a range loop—even direct key lookups and writes from different goroutines can trigger the panic.

Why Go maps aren't thread-safe

Maps in Go are optimized for single-threaded use. They grow dynamically, rehashing buckets as load increases. When a map grows, every bucket's contents shift. If one goroutine reads bucket positions while another writes and triggers a resize, the reader sees corruption and panics.

This is a deliberate design choice. Making maps thread-safe by default would add overhead—a lock or atomic operations on every access—for code that doesn't need it. Single-threaded programs, one-off reads before launching workers, and other common patterns would pay the cost. Instead, Go lets you choose your synchronization strategy based on your workload.

Go's race detector (go run -race) can catch many of these bugs during development. It instruments your code to detect reads and writes from different goroutines and prints a report. Always run tests with -race before committing.

Solution 1: Protect with sync.Mutex

For simple, low-contention maps, a sync.Mutex is your first choice. Wrap the map and all operations that touch it:

package main

import (
	"fmt"
	"sync"
)

type SafeMap struct {
	mu sync.Mutex
	m  map[string]int
}

func (sm *SafeMap) Set(key string, value int) {
	sm.mu.Lock()
	defer sm.mu.Unlock()
	sm.m[key] = value
}

func (sm *SafeMap) Get(key string) (int, bool) {
	sm.mu.Lock()
	defer sm.mu.Unlock()
	val, ok := sm.m[key]
	return val, ok
}

func (sm *SafeMap) Range(fn func(key string, value int) bool) {
	sm.mu.Lock()
	defer sm.mu.Unlock()
	for k, v := range sm.m {
		if !fn(k, v) {
			break
		}
	}
}

func main() {
	sm := &SafeMap{m: make(map[string]int)}

	// Safe concurrent writes and reads
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 100; j++ {
				sm.Set(fmt.Sprintf("key_%d_%d", id, j), id*100+j)
			}
		}(i)
	}
	wg.Wait()

	sm.Range(func(k string, v int) bool {
		fmt.Printf("%s => %d\n", k, v)
		return true
	})
}

Pros: Simple, predictable, works for any access pattern. Cons: All operations lock the entire map, so high contention becomes a bottleneck.

Solution 2: Use sync.Map for mostly-reads workloads

If your access is read-heavy with occasional writes—caching, reference tables, event handlers—sync.Map is built for that. It uses a clever design: reads hit a lock-free structure, and writes are optimized by deferring deletions and using atomic operations:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var m sync.Map

	// Load and Store are safe concurrent
	var wg sync.WaitGroup

	// Writers
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 100; j++ {
				m.Store(fmt.Sprintf("key_%d_%d", id, j), id*100+j)
			}
		}(i)
	}

	// Readers
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 100; j++ {
				m.LoadOrStore(fmt.Sprintf("key_%d_%d", id, j), 0)
			}
		}(i)
	}

	wg.Wait()

	// Iterate with Range
	m.Range(func(key, value interface{}) bool {
		fmt.Printf("%v => %v\n", key, value)
		return true
	})
}

Pros: Zero-copy reads, no locks for Load and Store. Cons: No typed keys/values (you get interface{}), and Range operations still serialize; not ideal for heavy-write workloads.

Solution 3: Shard for high contention

When you have many goroutines writing concurrently, sharding distributes the load across multiple small maps. Each shard has its own lock, reducing contention:

package main

import (
	"fmt"
	"hash/fnv"
	"sync"
)

type ShardedMap struct {
	shards []*mapShard
	count  int
}

type mapShard struct {
	mu sync.Mutex
	m  map[string]int
}

func NewShardedMap(shardCount int) *ShardedMap {
	shards := make([]*mapShard, shardCount)
	for i := 0; i < shardCount; i++ {
		shards[i] = &mapShard{m: make(map[string]int)}
	}
	return &ShardedMap{shards: shards, count: shardCount}
}

func (sm *ShardedMap) shard(key string) *mapShard {
	h := fnv.New32a()
	h.Write([]byte(key))
	return sm.shards[h.Sum32()%uint32(sm.count)]
}

func (sm *ShardedMap) Set(key string, value int) {
	shard := sm.shard(key)
	shard.mu.Lock()
	defer shard.mu.Unlock()
	shard.m[key] = value
}

func (sm *ShardedMap) Get(key string) (int, bool) {
	shard := sm.shard(key)
	shard.mu.Lock()
	defer shard.mu.Unlock()
	val, ok := shard.m[key]
	return val, ok
}

func main() {
	sm := NewShardedMap(16)

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 100; j++ {
				sm.Set(fmt.Sprintf("key_%d_%d", id, j), id*100+j)
			}
		}(i)
	}
	wg.Wait()

	fmt.Println("Sharded map completed without panic")
}

Pros: Scales well with many writers; each shard has minimal contention. Cons: Complexity, and Range operations must lock all shards sequentially.

Detecting and debugging these errors

When a concurrent map write error happens in production, the panic hits the error handler, and you lose context about what goroutine wrote which key. That's where distributed tracing and error tracking become essential. If you're using LightTrace (Sentry-SDK-compatible), the panic triggers an error event, and you can read the stack trace to see the call chain.

Always add goroutine context to your tracing spans. If you're using Sentry's Go SDK with LightTrace, set up distributed traces to capture which goroutines accessed the map and in what order. This turns a bare panic into a debugging timeline.

Catch these errors early:

  • Run tests with go test -race in CI, not just locally.
  • Use golangci-lint with the race detector integration.
  • Log lock acquisitions and releases in development if you suspect timing issues.
  • Profile lock contention with go tool pprof to identify hot paths.

Choosing the right pattern

Pick based on your workload:

  • Low contention, mixed reads and writes: sync.Mutex around a regular map.
  • Mostly reads, occasional writes: sync.Map (no generics overhead, typed wrappers available).
  • Many concurrent writers: Sharding with a hash function to distribute keys.
  • Single goroutine: Plain map with no synchronization (fastest).

The Go concurrent map writes fatal error race condition is preventable. Test with -race, wrap your maps, and monitor panics in production. When they do escape to production, error tracking best practices mean you'll see them immediately, stack trace and all.

Start tracking errors in minutes

Start tracking Go panics and distributed traces for free with LightTrace—point your Sentry SDK at our hosted platform and get a complete picture of concurrent errors in seconds.

Remember: the cost of synchronization is small compared to the cost of a concurrent map write panic in production. Pick one of these patterns, test it, and move on.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.