Maps in Go and the concurrent map writes error
1. Overview
Maps are the most used data structure in Go after slices. However, maps are not safe when multiple goroutines access them simultaneously. If you write to a map from multiple goroutines, your program may crash with the message fatal error: concurrent map writes.
This article reviews the basic operations with maps. Then it explains why the error occurs, how to detect it with the race detector, and three ways to fix it: sync.Mutex, sync.RWMutex, and sync.Map. The examples use Go 1.22 and later.
2. Maps in Go: what you need to know
2.1. Basic operations
package main
import "fmt"
func main() {
stock := map[string]int{"apple": 10, "banana": 5}
stock["orange"] = 7
qty, ok := stock["grape"]
fmt.Println(qty, ok)
delete(stock, "banana")
fmt.Println(len(stock))
}
Explanation:
map[string]int{...}creates a map with keys of typestringand values of typeint.stock["grape"]returns the zero value (0) if the key doesn’t exist. Theokvariable tells you whether the key actually exists in the map.deleteremoves a key. Callingdeletewith a non-existent key doesn’t cause an error.
0 false
2
2.2. Nil maps
Declaring var m map[K]V without initialization gives you a nil map. Reading from a nil map is fine, but writing to it panics:
package main
func main() {
var orders map[string]int
_ = orders["A-001"] // OK, returns 0
orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map
The fix is to always initialize the map using make(map[string]int) or a literal map[string]int{}. This error is common when a map is a field in a struct and you forget to initialize it in the constructor.
2.3. Maps are reference types; iteration order is randomized
When you assign a map to another variable or pass it to a function, both refer to one underlying data structure. So two goroutines receiving “the same map” are actually sharing one piece of memory. This is the root of the problem in the next section.
Also, the iteration order of for k, v := range m is deliberately randomized by the runtime. Don’t write code or tests that depend on that order. If you need a fixed order, collect the keys, sort them, then iterate.
3. The concurrent map writes error
3.1. Reproducing the error
Suppose you count page visits by user, with each request running on its own goroutine:
package main
import (
"fmt"
"sync"
)
func main() {
visits := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
visits[fmt.Sprintf("user-%d", j%10)]++
}
}()
}
wg.Wait()
fmt.Println(len(visits))
}
Run it a few times, and you’ll almost certainly see:
fatal error: concurrent map writes
goroutine 23 [running]:
main.main.func1()
/tmp/visits/main.go:17 +0x...
...
exit status 2
The line visits[...]++ consists of one read and one write. When 100 goroutines do this at the same time, many may modify the map’s internal structure (buckets, resize process) simultaneously. Go’s runtime has a simple check: the map holds a “writing” flag. If a goroutine sees this flag is already set when it starts writing, the runtime halts the program immediately.
There’s a similar variant when one goroutine reads while another writes:
fatal error: concurrent map read and map write
3.2. Why you can’t recover from it
Many people try to wrap the code with recover:
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
visits["user-1"]++
}()
This doesn’t work. concurrent map writes is not a panic. It’s a fatal error that the runtime throws, which terminates the entire process. recover can’t catch it. This choice is deliberate: if a map is corrupted, continuing to run is more dangerous than stopping.
Note: The runtime’s detection is only “best-effort”. The program not crashing doesn’t mean your code is safe. You might just not have hit the exact moment when two goroutines collide, and your data could already be wrong silently.
3.3. Early detection with the race detector
A more reliable tool is the race detector, enabled with the -race flag:
go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
runtime.mapaccess1_faststr()
main.main.func1()
/tmp/visits/main.go:17 +0x...
Previous write at 0x00c000... by goroutine 7:
runtime.mapassign_faststr()
main.main.func1()
/tmp/visits/main.go:17 +0x...
==================
The race detector pinpoints the exact read and write lines that conflict. You should enable -race when running tests in CI. Programs run with -race are slower and use more memory, so you typically don’t use this flag for production builds.
4. First fix: sync.Mutex
The most common and straightforward approach is to wrap the map in a struct with a mutex, then allow access to the map only through methods:
package visits
import (
"maps"
"sync"
)
type VisitCounter struct {
mu sync.Mutex
counts map[string]int
}
func NewVisitCounter() *VisitCounter {
return &VisitCounter{counts: make(map[string]int)}
}
func (c *VisitCounter) Inc(user string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[user]++
}
func (c *VisitCounter) Get(user string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.counts[user]
}
func (c *VisitCounter) Snapshot() map[string]int {
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.counts)
}
Explanation:
- The
countsfield is lowercase so code outside the package cannot access the map directly. IncandGetboth lock the mutex, so at any given moment only one goroutine operates on the map.Snapshotreturns a copy usingmaps.Clone(available since Go 1.21). If you returnedc.countsdirectly, the caller would iterate that map without a lock, and you’d be back to the original error.- The
NewVisitCounterconstructor initializes the map, avoiding the nil map error from section 2.2.
4.1. Writing tests with -race
Go doesn’t use JUnit or AssertJ. The standard testing package is sufficient for this example:
package visits
import (
"sync"
"testing"
)
func TestVisitCounter_ConcurrentInc(t *testing.T) {
counter := NewVisitCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Inc("alice")
}
}()
}
wg.Wait()
if got := counter.Get("alice"); got != 100_000 {
t.Fatalf("Get(alice) = %d, want 100000", got)
}
}
go test -race ./...
ok example.com/visits 1.214s
The test checks two things. First, the result is exactly 100,000, meaning no increments were lost. Second, the race detector reports no DATA RACE. Try removing c.mu.Lock() from Inc and run again—the test will fail immediately.
4.2. Common mutex mistakes
Copying a struct with a mutex. If you declare a method with a value receiver, like func (c VisitCounter) Inc(...), each method call uses a copy of the mutex. Locking the copy doesn’t protect anything. Always use a pointer receiver. go vet will warn about this via the copylocks check:
./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex
Holding the lock too long. Don’t make HTTP calls, query databases, or write files while holding the lock. Grab the data you need, release the lock, then do slow work.
Separated check-then-act. The code below has two separate lock acquisitions, so between them another goroutine can sneak in:
if counter.Get("bob") == 0 {
counter.Inc("bob") // another goroutine may have incremented first
}
If you need “check if not present then create” logic, write it as one method and hold the lock throughout.
5. Second fix: sync.RWMutex
For data that is read very frequently but written rarely—like a configuration cache or product price table—sync.RWMutex lets multiple goroutines read in parallel:
package pricing
import "sync"
type PriceTable struct {
mu sync.RWMutex
prices map[string]int64
}
func NewPriceTable() *PriceTable {
return &PriceTable{prices: make(map[string]int64)}
}
func (p *PriceTable) Price(sku string) (int64, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
v, ok := p.prices[sku]
return v, ok
}
func (p *PriceTable) SetPrice(sku string, cents int64) {
p.mu.Lock()
defer p.mu.Unlock()
p.prices[sku] = cents
}
RLock allows multiple goroutines to read simultaneously. Lock is exclusive: it waits for all readers to finish and blocks new readers until the write is done.
Note:
RWMutexis not always faster thanMutex. It has higher management overhead. When write contention is significant, or when the code inside the lock is very short,Mutexis often as fast or faster. Benchmark before switching.
An easy mistake: call p.mu.RLock(), then inside call another method that also does RLock. If a writer is waiting between the two calls, the program can deadlock. That’s because RWMutex blocks new readers when there’s a writer waiting.
6. Third fix: sync.Map
The sync package provides sync.Map, a map that’s safe for concurrent use without managing locks yourself:
package main
import (
"fmt"
"sync"
)
type Session struct {
UserID string
}
func main() {
var sessions sync.Map
sessions.Store("token-abc", Session{UserID: "alice"})
actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
fmt.Println(actual.(Session).UserID, loaded)
if v, ok := sessions.Load("token-abc"); ok {
fmt.Println(v.(Session).UserID)
}
sessions.Range(func(key, value any) bool {
fmt.Println(key, value.(Session).UserID)
return true
})
}
alice true
alice
token-abc alice
Explanation:
Storewrites a key/value pair.LoadOrStoreis an atomic operation. If the key already exists, the method returns the old value withloaded = trueand doesn’t overwrite it. This is the right way to handle check-then-act withsync.Map.Rangeiterates the map. Returningfalsefrom the callback stops early.
sync.Map has limitations you should know about:
- Keys and values have type
any, so you lose compile-time type checking and must use type assertions everywhere. - There’s no
Len()method. To count elements, you must iterate withRange. sync.Mapis optimized for two specific scenarios. One: each key is written once then read many times, like an append-only cache. Two: goroutines read and write on non-overlapping key sets.
Note: Outside those two scenarios, a regular
mapwithMutexorRWMutexis usually faster, more readable, and type-safe. Don’t automatically replace every map withsync.Map.
7. Which solution to choose?
| Scenario | Recommended choice |
|---|---|
| Mixed reads/writes, conditional logic | map + sync.Mutex |
| Reads dominant, writes rare | map + sync.RWMutex (benchmark first) |
| Append-only cache or each goroutine uses disjoint keys | sync.Map |
| Map used in only one goroutine | Plain map, no locking needed |
Another approach: let one goroutine exclusively own the map, with others sending requests via channels. This works well when map updates are tied to an existing event loop. For a simple counter, this is more verbose than a mutex with no clear benefit.
One more note on read-only maps: if a map is fully initialized before goroutines start and nothing writes to it afterward, concurrent reads by multiple goroutines are safe and need no locking.
8. Conclusion
Maps in Go are fast and convenient, but not safe for concurrent use. When multiple goroutines write, or when some read while others write, the runtime may halt with fatal error: concurrent map writes. This error can’t be recovered. A program not crashing doesn’t prove your code is correct, so run tests with go test -race to catch data races early.
In most cases, simply wrap the map in a struct with sync.Mutex, access via methods with pointer receivers, and return copies instead of the map itself. Use sync.RWMutex when reads truly dominate, and only reach for sync.Map in the specific scenarios it was designed for.