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 type string and values of type int.
  • stock["grape"] returns the zero value (0) if the key doesn’t exist. The ok variable tells you whether the key actually exists in the map.
  • delete removes a key. Calling delete with 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 counts field is lowercase so code outside the package cannot access the map directly.
  • Inc and Get both lock the mutex, so at any given moment only one goroutine operates on the map.
  • Snapshot returns a copy using maps.Clone (available since Go 1.21). If you returned c.counts directly, the caller would iterate that map without a lock, and you’d be back to the original error.
  • The NewVisitCounter constructor 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: RWMutex is not always faster than Mutex. It has higher management overhead. When write contention is significant, or when the code inside the lock is very short, Mutex is 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:

  • Store writes a key/value pair.
  • LoadOrStore is an atomic operation. If the key already exists, the method returns the old value with loaded = true and doesn’t overwrite it. This is the right way to handle check-then-act with sync.Map.
  • Range iterates the map. Returning false from 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 with Range.
  • sync.Map is 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 map with Mutex or RWMutex is usually faster, more readable, and type-safe. Don’t automatically replace every map with sync.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.

golangmapconcurrencygoroutinesync