What happens when two goroutines write to the same Go map without a lock?
answer
- maps carry no lock of their own
- the runtime checks map operations
- the whole process, not one goroutine
- a fatal error, not a panic
basics
~20 sGo's built-in map is not safe for concurrent writes. The runtime notices the overlapping write and aborts the whole process with 'fatal error: concurrent map writes'. It is not a panic and the program does not continue past it.
solid answer
~50 sA Go map has no locking of its own, and the runtime checks for overlapping access on map operations. If one goroutine is writing while another writes, the runtime throws `fatal error: concurrent map writes` and terminates the entire process; the mixed cases produce `fatal error: concurrent map read and map write` and `fatal error: concurrent map iteration and map write`. This is a fatal runtime error rather than a panic: the process dies on the spot with a goroutine dump, no stack is unwound and no deferred function runs. The check is opportunistic, not a guarantee, so unsynchronised map code can run happily for weeks and then abort under load. The fix is to guard the map with a `sync.Mutex` or `sync.RWMutex`, give each goroutine its own map and merge later, or use `sync.Map` where its access pattern fits.
code
go · 9 lines// counters is touched by every reporter goroutine.
var counters = map[string]int{}
func record(label string) {
counters[label]++ // unsynchronised write
}
// Two goroutines in record at the same time can abort the whole
// process with: fatal error: concurrent map writesgo deeper
Be ready to say plainly that a Go map is not safe for concurrent writes, that the runtime aborts the whole program when it catches one, and that a mutex around every access is the standard fix.
Explain the difference between a fatal runtime error and a panic here, name the read-and-write and iteration variants of the message, and be able to justify choosing a mutex, sharding, or sync.Map for a given access pattern.
An interviewer expects you to treat the abort as an operational event: the detection is opportunistic, so a clean production run proves nothing, and the design fix is usually to remove the sharing rather than to serialise it more carefully.
Frame it as a state-ownership rule for the codebase. Decide whether shared mutable maps are allowed at all, and where aggregation state should live, so that this class of crash cannot be reintroduced by the next contributor.
## The short version Go's built-in `map` is not safe for concurrent use when any goroutine is writing. If two goroutines write to the same map at the same time, or one writes while another reads or ranges, the Go runtime detects it and kills the process with a message that begins `fatal error: concurrent map writes`. This is not the same thing as a panic. A panic is a language-level event that travels up one goroutine's stack. A `fatal error:` line comes from the runtime deciding that its own invariants are broken and that continuing would be worse than stopping. The process prints a diagnostic plus goroutine stacks to standard error and exits with status 2. ## The three messages you will actually see - `fatal error: concurrent map writes` - two writes overlapped. - `fatal error: concurrent map read and map write` - a read overlapped a write. - `fatal error: concurrent map iteration and map write` - a `for ... range` over the map overlapped a write. All three mean the same thing at the design level: a map is being shared across goroutines with at least one writer and no synchronisation. ## Reads alone are fine Any number of goroutines may read the same map concurrently, as long as none of them writes. A map that is fully built during startup and only read afterwards needs no lock at all, and that is a common and correct pattern: build a lookup table in `init` or in `main` before any worker starts, then hand it out read-only. The moment one goroutine can write, every access has to be serialised, including reads and ranges. ## Why detection is unreliable, and why that matters The runtime's check is cheap and opportunistic. It fires when two operations actually collide in a way the runtime observes. It is not an analysis of your program, and it is not a promise. Consequences: - A racy map can survive an entire test suite and a staging soak, then abort in production on the day traffic doubles. - Absence of the message proves nothing about correctness. - When it does fire, it fires hard: the whole process, not the offending goroutine. The abort is a feature, not a bug. Without the check, concurrent writes would silently corrupt the map's internal structure and you would be debugging impossible lookups and wrong values months later. A loud immediate crash with a stack for every goroutine is a much better outcome. ## What it looks like in a real service The classic shape is an aggregation daemon: counters accumulated per label into one shared map, updated by several reporter goroutines and read by whatever exposes them. Each individual update looks trivially small - `counters[label]++` - which is exactly why nobody notices it is a read-modify-write on shared state. The daemon runs fine at low volume, because collisions are rare, and dies once the update rate rises. The log you get in the postmortem starts with the `fatal error:` line, then a stack for the goroutine that hit the check, then stacks for the rest. Two goroutines sitting in the same `record`-style helper is the tell. ## Fixing it - **A mutex around the map.** The default answer. `sync.Mutex` if reads and writes are comparable in volume, `sync.RWMutex` if reads dominate and the critical sections are big enough to be worth the extra bookkeeping. - **`sync.Map`.** Purpose-built for two narrow shapes: a key written once and read many times, and disjoint key sets per goroutine. It is not a general faster map, and for a counter map with heavy contended updates it is often slower than a plain map behind a mutex. - **Shard the state.** Give each worker its own map and merge on a fixed interval or at report time. This removes the contention entirely rather than serialising it. - **Own the map in one goroutine.** Send updates over a channel to a single goroutine that owns the map. Nothing else touches it, so nothing needs a lock. ## What not to conclude Do not conclude that only the goroutine that hit the check dies - the process dies. Do not conclude that the map was made safe because you added a lock on one code path; every path that touches the map has to hold the same lock. And do not conclude that a clean production run means the map is properly synchronised.
- Is it safe for many goroutines to read the same Go map concurrently?Yes, as long as no goroutine writes. Concurrent reads of a map need no synchronisation at all, which is why a table built at startup and never modified can be shared freely. Once a single writer exists, every access has to be serialised - the runtime also aborts with 'concurrent map read and map write'.
- Does the runtime guarantee it will catch every unsynchronised map write?No. The check is opportunistic and only fires when operations actually collide in an observable way. Racy map code routinely survives testing and staging and then aborts in production under higher update rates, so a clean run is not evidence that the map is properly guarded.
- Why isn't Go's built-in map simply made safe for concurrent use?Because the overwhelming majority of maps are used by one goroutine and would pay for locking they never need. Go leaves the choice to the author: a plain map plus a mutex, sharded maps, or sync.Map for its narrow patterns. The runtime check exists so that getting it wrong is loud rather than silently corrupting.
It is a shared ledger with no pen queue. Two people writing on the same line at once do not produce a merged entry; the auditor sees the mess, stops the whole office and files a report.
saying these in an interview costs you the question
- Claims Go's built-in map is internally synchronised
- Says only the offending goroutine dies, not the process
- Thinks concurrent reads with no writer also abort
- Assumes the runtime catches every unsynchronised map write
- Reaches for sync.Map as a general faster map