The panic that stops your server
You wrote a web server that caches user sessions. You added goroutines to handle requests faster. The first thousand requests work perfectly. Then the server crashes with a stack trace pointing to a map assignment. The error message screams fatal error: concurrent map writes. You didn't do anything wrong logically. You just forgot that Go maps are not built for chaos.
The panic is a safety feature. Go detects that two goroutines touched the map at the same time and stops the program immediately. The map is already corrupted. Continuing would mean returning wrong data or crashing in unpredictable ways later. The panic forces you to fix the race condition now.
Maps are fast, not safe
Go maps are hash tables optimized for speed. They use buckets to store keys and values. When the map grows, it allocates new buckets and moves data over. This resizing happens automatically. It also happens while you are writing to the map.
Imagine a library where two librarians rearrange books on the same shelf at the exact same moment. One librarian moves a book to a new slot. The other librarian grabs the empty slot thinking it's free. The catalog gets out of sync. Books disappear. The shelf structure breaks.
That is what happens inside a map during concurrent writes. The internal pointers get tangled. Go cannot guarantee the data is correct, so it panics. Reads are also unsafe if a write is happening at the same time. A read might see a partially updated bucket and return garbage or crash.
Maps have no built-in locking. Adding locks to every map operation would slow down the single-threaded case. Go leaves synchronization to you. You decide when protection is needed. This keeps the language fast and gives you control.
Minimal fix with Mutex
The standard solution is a mutex. A mutex is a mutual exclusion lock. It ensures only one goroutine accesses the map at a time. Wrap every read and write in a lock.
package main
import (
"fmt"
"sync"
)
// Counter tracks hits per key using a thread-safe map.
type Counter struct {
mu sync.Mutex
data map[string]int
}
// NewCounter creates a ready-to-use counter.
func NewCounter() *Counter {
return &Counter{
data: make(map[string]int),
}
}
// Increment adds one to the count for key.
func (c *Counter) Increment(key string) {
c.mu.Lock()
// Lock blocks other goroutines until this one finishes.
c.data[key]++
c.mu.Unlock()
}
// Get returns the current count for key.
func (c *Counter) Get(key string) int {
c.mu.Lock()
// Reads also need protection when writes occur.
val := c.data[key]
c.mu.Unlock()
return val
}
func main() {
c := NewCounter()
var wg sync.WaitGroup
// Spawn ten goroutines to hammer the map.
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
c.Increment("test")
}
}()
}
wg.Wait()
fmt.Println(c.Get("test"))
}
The mutex serializes access. When a goroutine calls Lock, it grabs the key. If another goroutine tries to call Lock, it blocks and waits. The first goroutine calls Unlock, releases the key, and one of the waiting goroutines wakes up. The map sees one writer at a time. The internal structure stays consistent. The panic disappears.
Locks are expensive. Hold them for the shortest time possible. Do not do network calls or heavy computation while holding a lock. Unlock as soon as the map operation is done.
Realistic cache with RWMutex
A simple mutex works, but it blocks readers too. If your cache has many reads and few writes, a mutex creates a bottleneck. Readers queue up behind each other even though they don't modify the map.
Use sync.RWMutex for read-heavy workloads. It supports multiple readers at once. Writers still block everyone. This reduces contention when reads dominate.
package main
import (
"fmt"
"sync"
)
// Cache stores values with thread-safe access.
type Cache struct {
mu sync.RWMutex
data map[string]string
}
// NewCache initializes a new cache instance.
func NewCache() *Cache {
return &Cache{
data: make(map[string]string),
}
}
// Get retrieves a value from the cache.
// It returns the value and a boolean indicating presence.
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
// RLock allows multiple readers simultaneously.
val, ok := c.data[key]
c.mu.RUnlock()
return val, ok
}
// Set adds or updates a value in the cache.
func (c *Cache) Set(key, value string) {
c.mu.Lock()
// Lock blocks all other readers and writers.
c.data[key] = value
c.mu.Unlock()
}
// Delete removes a key from the cache.
func (c *Cache) Delete(key string) {
c.mu.Lock()
// Delete is a write operation and requires exclusive access.
delete(c.data, key)
c.mu.Unlock()
}
func main() {
cache := NewCache()
var wg sync.WaitGroup
// Writers update the cache concurrently.
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
cache.Set(fmt.Sprintf("key-%d", id), fmt.Sprintf("val-%d", j))
}
}(i)
}
// Readers access the cache concurrently.
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
cache.Get("key-0")
}
}()
}
wg.Wait()
}
The RWMutex tracks the number of active readers. When a writer requests the lock, it waits for all readers to finish. New readers also wait until the writer is done. This prevents starvation where writers never get a turn.
Read locks are cheap. Write locks block everyone. If writes become frequent, the RWMutex degrades to a regular mutex. Profile your access pattern. Switch back to sync.Mutex if writes are common.
Pitfalls and silent killers
Mutexes solve the panic, but they introduce new risks. The most common mistake is forgetting to unlock. If a goroutine holds a lock and panics or returns early, the lock stays held. Other goroutines block forever. The server hangs.
Use defer to ensure the unlock happens. Place defer mu.Unlock() immediately after mu.Lock(). The unlock runs when the function returns, even if a panic occurs.
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
// Unlock runs automatically when this function returns.
c.data[key] = value
}
defer adds a tiny overhead. If you are in a hot loop running millions of times per second, manual Unlock might save CPU cycles. The risk of a forgotten unlock usually outweighs the micro-optimization. Stick to defer unless profiling proves the lock is the bottleneck.
Another pitfall is iterating over a map while writing. Go panics with fatal error: concurrent map iteration and map write. You must hold the lock for the entire iteration. Copy the keys or values if you need to process them outside the lock.
func (c *Cache) Keys() []string {
c.mu.RLock()
defer c.mu.RUnlock()
// Copy keys to a slice to avoid holding the lock during processing.
keys := make([]string, 0, len(c.data))
for k := range c.data {
keys = append(keys, k)
}
return keys
}
Holding a lock while iterating can block writers for a long time. If the map is large, copy the data and release the lock quickly. Process the copy without the lock. This reduces contention.
Channels coordinate goroutines. Mutexes protect shared state. They solve different problems. Use a mutex when multiple goroutines need to update the same data structure. Use a channel when you want to pass data between goroutines or signal events. Mixing patterns without care leads to deadlocks.
Catching races before users do
Go has a built-in race detector. It instruments memory accesses and reports races at runtime. Run your tests or binary with the -race flag.
go test -race ./...
go run -race main.go
The detector finds races that your code might not trigger immediately. It reports the exact lines involved and shows the stack traces of the conflicting goroutines. The output is verbose but precise.
Use the race detector in CI pipelines. Fail the build if a race is detected. This catches concurrency bugs early. The detector adds overhead, so do not run it in production. It is a development tool.
Running with -race is a community standard. Many projects enforce it in CI. It costs nothing to add and saves hours of debugging later. Trust the detector. It finds bugs you cannot see.
Decision matrix
Pick the synchronization tool that matches your access pattern. Concurrency is hard. The wrong tool adds complexity and performance costs.
Use a sync.Mutex when you have frequent writes or mixed read/write access patterns. It is the simplest tool for general protection.
Use a sync.RWMutex when reads vastly outnumber writes. Multiple readers can proceed at once, reducing contention.
Use sync.Map when you have keys that are mostly written once and read many times, or when different goroutines operate on distinct keys without overlap. It is optimized for specific concurrent patterns, not a general replacement.
Use separate maps per goroutine and merge results later when you can partition the work. This avoids locks entirely and scales better on multi-core systems.
Use atomic operations for simple counters when you only need to increment or decrement a single integer value. Atomics are faster than mutexes for single-value updates.