Deep Engineering
Intermediate·Published·35 MIN

Maps in Go: a group of eight, the 7/8 threshold, and a miss that costs more than a hit

A maps interview climbs a ladder: what is in the variable — how does lookup work — how does it grow — what is the complexity — why is iteration order "random" — what happens on concurrent access. This lesson climbs all of it, showing the mechanism at every rung: why the map keeps an eighth of its slots empty on purpose, why growth happens at 7/8 rather than when full, and why a miss takes longer to look up than a hit.

Full technical treatment

TL;DR

A map answers one question: what is stored under this key. An absent key is not an error, it is the zero value — and by value it is indistinguishable from a key whose value really is zero; only the two-result form v, ok := m[k] tells them apart. Keys must be comparable, iteration order is not guaranteed, and a map does not survive concurrent access from several goroutines: guarding it is your job.

Hence what breaks. A nil map can be read, ranged over and even deleted from — six operations work, the seventh crashes: assignment to entry in nil map. A non-comparable key is caught by the compiler — invalid map key type []int — except in map[any]T, where the check moves to run time. A concurrent write is a fatal error, not a panic: recover will not help, the stack is not unwound and deferred calls do not run. And a variable of type map holds a pointer to a runtime structure — which is why a map passed into a function changes for the caller.

Beyond that come the go1.24 implementation and the numbers. In the current implementation lookup walks groups of eight slots and stops only on finding a free slot — hence the thing nobody expects: a miss costs more than a hit. Measured on this machine: 19.80 ns against 28.96 — a factor of 1.46. The implementation keeps an eighth of the slots empty on purpose: the table grows at a load factor of 7/8, not "when it fills up" — growths were measured at sizes 9, 15, 29, 57, 113, 225, 449, exactly "seven eighths of capacity plus one". The complexity is O(1) on average, but an insert that lands on a growth costs O(n): the whole table is rebuilt. About iteration order the contract says one thing — it is not guaranteed; the observable implementation of that is not a shuffle but a random starting point, which is why code depending on order passes tests and breaks in production. And a map does not give memory back: measured 4617 KB before every key was deleted and 4618 after — neither delete nor clear shrinks the table.

Where to start
Before this lesson it is enough to understand
  • what a key-value pair is: you store something under a key and read it back by the same key;
  • the same key appears in a map only once;
  • a function receives its argument as a copy — and what exactly was copied decides whether the caller sees any changes.
You do not need to know in advance
  • hashing, slot groups, the control byte, the load factor, Swiss tables;
  • how a fatal error differs from a panic, what sync.Map is, and why the hash is seeded randomly;
  • the v, ok := m[k] form and the word "comparable" applied to a key type — both are explained along the way.

What is actually being asked

The ladder is almost always the same, and the first two rungs decide more than they look like they do:

  1. "What does m[k] return if the key is not there?" — the warm-up, which filters out everyone who expects an error.
  2. "And how do you tell an absent key from a zero value?" — this is where the content starts.
  3. "What happens if two goroutines write to it?" — checking whether you know this is not a panic.
  4. "How does the lookup work?" — whether you name the hash and the group of slots or stop at the words "hash table".
  5. "What happens when the map grows?" — whether you know the threshold and can name it as a number.
  6. "What is the complexity?" — whether you separate the average from the worst case.
  7. "Why is the iteration order random?" — a trick question: it is not random.

The lesson walks that ladder, and it walks it in one direction: first what a map promises, then how the implementation keeps that promise. The numbers come last — as a check on a prediction, not as a pile of surprising facts.

Base: what a map does

A map solves one problem: hold key-value pairs so that a key gets you its value directly instead of scanning everything. Hence the ordinary work with it: you store under a key, and you read back by the same key.

You may ask a map about any key — including one it does not hold. That is not an error: instead you get back the zero value of whatever type the map stores — zero for numbers, the empty string for strings. Nothing breaks, and that is the main subtlety at the entrance: did the zero come back because the key is absent, or because the value stored under it really is zero? By the value alone the two cases are indistinguishable. So reading has a second form — the one that returns not only the value but also the answer to "was the key there at all". Everything the lesson later calls comma-ok is that form.

Not everything can be a key. A map finds a key by comparison, so the key type has to support comparison for equality: numbers, strings and structs built of such fields qualify; a slice or another map does not.

You may range over a map, but no iteration order is promised: the language calls a map an unordered group of elements, and two ranges over the same map are free to hand back the entries differently.

And the last thing to know before any implementation detail: a map is not protected by anything. While one goroutine works with it all is well; the moment there are two and at least one of them writes, the synchronisation is yours to provide.

That is already enough to answer the basic interview question. What follows is why each of those rules is checkable and what exactly happens when one is broken — and, at the end, how the implementation manages to find a value in constant time and what it pays for that.

Mechanism 1: what a map promises

Now the same thing, but checked by a run and stated in the words of the specification. Start with what will not change with the Go version or the machine — the contract. Everything else in the lesson explains how the implementation keeps it, but the contract itself does not depend on the implementation.

language contractLanguage guarantees. Under “Deeper” comes the go1.24 implementation — which has changed before and will change again.

An absent key is not an error, it is the zero value. A run of bench/gomap/contract.go:

scores["alice"]              10
scores["carol"]              0
scores["dave"]               0

Three lines, two different cases — and by value they are indistinguishable. carol has a key and her score really is zero; dave has no key at all. This is the first trap of the topic: if m[k] == 0 does not answer the question "is the key there".

What tells them apart is a second variable, the form known as comma-ok:

v, ok := scores["carol"]     0 true
v, ok := scores["dave"]      0 false

The rule is worth saying verbatim: ok answers the question of presence, the value answers the question of content, and the second must not stand in for the first. A bug from this neither crashes nor logs — it quietly treats an absent user as a user with zero.

A nil map can be read, ranged over and even deleted from — but not written to. The same run:

nilMap == nil                true
len(nilMap)                  0
nilMap["anything"]           0
_, ok := nilMap["anything"]  false
for range nilMap             0
delete(nilMap, ...)          ok
nilMap["anything"] = 1       panic: assignment to entry in nil map

Six operations work, the seventh crashes. The specification puts it in one sentence: A nil map is equivalent to an empty map except that no elements may be added.

The treachery is precisely in those first six lines. A nil map inside a struct somebody forgot to initialise behaves perfectly normally — until the first write, which may happen a week later and in different code.

Keys must be comparable, and the compiler checks it. bench/gomap/contract.sh tries to build a map with a slice key:

badkey.go:4:17: invalid map key type []int
--- go build exit code: 1

Structs and arrays are comparable element-wise and make fine keys; slices, maps and functions do not. What matters here is when this comes out: at build time, not in production. "A map with a slice key" is not a rare bug — it is a program that does not exist.

There is one exception, and it is the dangerous one: in map[any]T the key type is formally comparable, so the check moves to run time. Put a slice in and you get a panic on the line that put it there.

Iteration order is not guaranteed. That too is part of the contract rather than a property of the implementation: you must not rely on the order, full stop. How that lack of guarantee is implemented is a detail that makes such bugs harder to catch with tests than you would think — the lesson returns to it under "Deeper".

Mechanism 2: maps and goroutines

This question comes second rather than last because it is about the safety of the contract, not about a subtlety: a map in Go is protected by nothing, and the cost of getting this wrong is higher than anything else in the lesson.

"What happens if two goroutines write to one map" tests one thing: whether you know this is not a panic.

fatal error: concurrent map writes

There are three messages, and they say what collided: concurrent map writes, concurrent map read and map write, concurrent map iteration and map write.

And recover will not help. The difference is not cosmetic: panic unwinds the stack, runs deferred calls and can be caught; fatal does none of that — the process stops where it is. The comment on fatal itself names racing map writes as the model case: fatal is equivalent to throw, but is used when user code is expected to be at fault for the failure, such as racing map writes.

Why stopping was chosen rather than "whatever happens". The Go memory model explains it outright: races on multiword data structures can lead to inconsistent values not corresponding to a single write — and goes on to arbitrary memory corruption. Failing loudly beats silently corrupting somebody else's data.

A separate note on the race detector. The check inside the map is not it: it runs always, in an ordinary build, without -race. But it is not a guarantee: two goroutines can miss each other, and then the program simply corrupts the table. go test -race finds such races more reliably, because it watches accesses rather than a flag.

The cure: an ordinary mutex beside the map. sync.Map looks like the ready-made answer, but it is tuned for a different profile — many reads and few writes — and loses to a mutex-guarded map on an even mix.

Mechanism 3: why changes are visible after passing to a function

Here the lesson moves from what a map promises to how the variable is built — and the first observation is usually noticed before its explanation.

A map passed into a function changes for the caller:

GO
func fill(m map[string]int) { m["a"] = 1 }   // visible outside

The explanation is simple: map[K]V is a pointer to a runtime structure, not the structure itself. The pointer is copied; the table behind it is shared.

This differs from a slice in a more interesting way than it looks. With a slice a function can change the elements but cannot lengthen it — because the length lives in the copied header. With a map "lengthening" works too, because the size lives behind the pointer rather than beside it.

Note the direction of the reasoning. "A map is a pointer" explains nothing on its own and is easy to memorise wrong; it is useful precisely because two observable facts follow from it — the shared table after passing, and the ban on taking the address of an element.

A nil map can be read but not written. The specification puts it in one sentence: A nil map is equivalent to an empty map except that no elements may be added.

GO
var m map[string]int
fmt.Println(m["missing"], len(m))   // 0 0 — works
m["present"] = 1                    // panic: assignment to entry in nil map

The trap here is that reading works: a nil map inside a struct somebody forgot to initialize behaves perfectly normally right up to the first write.

You cannot take the address of an element. &m[k] does not compile, and the reason is spelled out under "Deeper": on growth the entries physically move, and the address would stop being valid. With a slice you can take an address, because there the move is visible from outside through append; in a map it happens silently.

Mechanism 4: edge cases that cost a crash

The key must be comparable. The specification: The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice. For map[any]T this is checked at run time: put a slice in and you get a panic where you put it.

And the subtlest case: NaN. float64 is a comparable type, yet NaN != NaN. A map finds a key by comparison, so a write under NaN lands in the map but can never be found:

GO
nan := math.NaN()
m := map[float64]int{}
m[nan] = 1
m[nan] = 2
// len(m) == 2 — TWO entries under one "key"
// m[nan] == 0 — neither can be found
// delete(m, nan) — deletes nothing

Entries that can be neither read nor deleted. The only way to be rid of them is to throw the map away. In an interview this is an excellent answer to "can a key be present and still unreachable".

Deeper: how a map is built inside

Everything above is the contract: it does not depend on the Go version and will not change. What follows is a different kind of claim — the layout of today's implementation. It explains numbers that would otherwise look arbitrary, but it is not a rule of the language: before Go 1.24 a map was built differently, and it will be built differently again.

What a map needs a hash for

Now the implementation begins, and it is worth taking in two passes. First the ordinary hash table as any textbook describes it: without it, groups of eight and control bytes have to be memorised rather than understood.

implementation detail · Go 1.24Everything from here to the end of the section is how the current implementation works. It is not in the specification and has changed before: prior to Go 1.24 a map was built differently.

A map has one job: find a value by its key in constant time. Scanning is out — that is linear. So the position of the value has to be computed rather than searched for.

Three consequences follow immediately, and they explain everything after:

  • The hash must be fast, or the saving on the lookup is eaten by computing it. Which is why a key has to be comparable and hashable — a type whose hash cannot be computed does not belong in a map.
  • Different keys can produce the same index. That is a collision, and it is not rare but normal: there are always fewer buckets than possible keys. So the bucket must also hold the key itself, and compare it.
  • A full table works badly. The fewer free slots, the longer the probe chains, so the table has to grow ahead of time.

That is the classical model. Now the specifics: Go 1.24 implements it as a Swiss Table, and three decisions of that design — groups, the control byte and the stopping rule — explain the measurements that would otherwise look arbitrary.

A group of eight and the stopping rule

The key's hash splits in two: the high bits pick a group, the low seven go into a control byte — a short tag for the slot.

A group is eight slots and eight control bytes laid out next to each other. A lookup compares the wanted tag against all eight at once — one instruction, not a loop. A matching tag does not yet mean a matching key (seven bits are few), but a non-match rules the key out for certain, so the full key comparison happens only for tags that matched.

The important part is the stopping rule, and it is what produces the unexpected consequence. The lookup walks groups until it finds the key or meets a group with a free slot. A free slot means "there would have been nowhere further to put this key, so it is not here".

Hence a prediction worth making before looking at any numbers: a miss should cost more than a hit. A hit stops on the key it found. A miss must walk to a free slot — and free slots are scarce, because the map runs at a load factor of 7/8.

Nearly everyone expects the opposite: "a miss found nothing, so it must be faster". The mechanism says otherwise, and a measurement below checks it.

How a map grows

"When does a map grow" almost always gets "when it fills up". That is wrong, and the number is known: at a load factor of 7/8.

The growths can be caught — by jumps in the allocation count. Here are the sizes at which they happened:

9  15  29  57  113  225  449

Check it yourself: 7/8 of 16 is 14, and the growth happened on the fifteenth entry. 7/8 of 32 is 28, growth on the twenty-ninth. And so on. The rule: growth at "seven eighths of capacity plus one", with the table doubling.

Why 7/8 rather than completely full. This is not a safety margin or a tuned constant — it is the direct price of the stopping rule from the previous part. Fill the table completely and no free slots remain, so a miss would have to be searched across the whole table. An eighth of empty slots is what buys the lookup somewhere to stop.

Here is the chain worth saying out loud in an interview: groups of eight give a fast tag comparison; stopping on a free slot makes a miss cost more than a hit; and for the sake of that stop the map holds a load factor of 7/8, that is, deliberately spends an eighth of its memory. Three facts, and they are not independent.

What it costs

Now that the mechanism has been laid out, the numbers stop being truisms and become a check on a prediction.

measured observationbench/gomap, go1.24.7 linux/amd64, two cores. The absolute nanoseconds depend on the machine; what carries meaning is the ratio and its sign.

The prediction from the stopping rule holds. Measured on a map of a hundred thousand entries:

time
hit19.80 ns
miss28.96 ns
ratio1.46

A miss costs half again as much as a hit — precisely because it has to reach a free slot. This is the case where the mechanism lets you predict the result before it is shown; if it still looks surprising, go back to the stopping rule.

Complexity, and what hides behind the average

The right answer is O(1) on average. But, as with slices, two different claims stand behind it, and the second usually gets lost.

The average O(1) rests on the hash spreading keys across groups evenly, so the lookup path length does not depend on the map size. It is the evenness, not "hash table magic": with a poor hash the whole thing degrades.

The worst case of a single operation is O(n). An insert that lands on a growth rebuilds the entire table: the new table is twice the size and every entry is rehashed. On a latency chart that is an outlier, and the bigger the map the bigger it is. For a service with a p99 budget, a map that grows under load is a known source of tails.

And the worst case of a lookup is O(n) too — if all keys land in one group. Go does not let that happen from outside: the map's hash is seeded with a random number at process start, so keys that all collide cannot be chosen in advance. This is protection against an algorithmic denial-of-service attack: back when there was no seeding, "send a hundred thousand keys with the same hash" took down web servers in other languages.

Hence a practical conclusion worth naming: if the final size is known, say it in make. Not for lookup speed — that will not change — but so that there are no growths at all. What exactly that saves is below.

delete, clear and the size hint

Three neighbouring questions, and intuition gets all three wrong.

delete does not return memory. Measured on a map of two hundred thousand entries:

stateoccupied
map full4617 KB
after delete of every key, len = 04618 KB
after clear()4618 KB

A map in Go does not shrink. The table stays the size it grew to; delete only marks the slot and clear zeroes the entries without touching the size. The only way to give memory back is to build a new map and drop the old one.

The practical consequence shows up in design questions: a cache in a map that once grew to peak load stays that size forever. The cure is not delete but rebuilding the map on a schedule.

A size hint shrinks not the map but the garbage. The same measurement, two maps of two hundred thousand entries:

allocationstotal allocatedfinal size
make(map[int]int)10449236 KB4617 KB
make(map[int]int, 200000)5144618 KB4617 KB

The finished map is the same. What differs by a factor of two is the work along the way: without the hint the runtime allocates and discards every intermediate table, which is exactly a doubling of allocated bytes. What is saved is the collector's work, not the map's memory.

Iteration order: the contract and the observation

Mechanism 1 said the iteration order is not guaranteed — that is the contract. This is about how the lack of guarantee is implemented, and the difference between those two statements costs bugs in production.

implementation detail · Go 1.24Below is an observation about the current implementation, not an alternative rule. Relying on «rotations» is exactly as forbidden as relying on the order at all: this explains why the bug is hard to catch, it does not license writing it.

"Map iteration order is random" is common knowledge. The claim is incomplete, and incomplete in the dangerous direction.

Iteration starts at a random point and from there walks the table in order. So there are as many distinct orders as there are entries — these are rotations of one sequence, not permutations of it. For a nine-entry map there are nine orders, not 362,880.

Why that is more dangerous than real randomness. Code that accidentally depends on key A coming before B would, under real shuffling, fail in roughly half of runs — and be caught on day one. Under rotation it fails only on the rotations that cut the tape between those two keys. That does not survive tests — it passes them, and breaks in production.

The right interview answer: "order is not guaranteed and must not be relied on; that non-guarantee is implemented with a random starting point rather than shuffling — which is why a dependency on order is caught by tests far less often than you would think".

How to answer in an interview

Short answer: a map holds key-value pairs addressed by key, and an absent key gives you not an error but the zero value; the v, ok := m[k] form is what tells it apart from a genuine zero. Add as a second sentence what gets asked straight after: the variable holds a pointer to a runtime structure, which is why a map passed into a function changes for the caller, and why a nil map can be read but not written.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

Describe the structure as a chain, not a list — and say out loud that this is how the current implementation is built, not a rule of the language. Groups of eight → tag compared against all eight at once → stop on a free slot → hence a miss costs more than a hit → and for that stop the load factor is held at 7/8. A chain shows understanding; a list of facts shows memorization.

Name the number. "On go1.24 it grows at 7/8 and the table doubles" weighs more than "when it fills up". If you remember, add that growths happen at 9, 15, 29, 57 entries: that is visible in a measurement and sounds like "I checked this".

Split the complexity. "O(1) on average; an insert that lands on a growth is O(n) because the whole table is rebuilt; the worst case of a lookup is O(n) too, but random hash seeding protects against chosen keys."

Do not call the iteration order random. Say "not guaranteed", and add that it is implemented with a random starting point, which is why a dependency on order passes tests. That is the answer people remember.

On concurrent access, say "not a panic". fatal error, recover does not help, cured with a mutex. Half of candidates answer "it will panic" — and that is exactly the difference the question is testing.

Next they ask

Next they ask

If a map is a pointer, why is make needed at all?

Short answer

Because a declared variable is a nil pointer with no structure behind it. The runtime can read through such a pointer (returning the zero value), but there is nowhere to write: there is no table and no counter.

That explains the asymmetry which otherwise looks arbitrary: reading from a nil map works, writing panics. The specification describes a nil map as equivalent to an empty one "except that no elements may be added".

Next they ask

Why can't you take the address of a map element?

Short answer

Because on growth the entries physically move to a new table, and the address would stop pointing at the right thing. The ban is at compile time — &m[k] simply does not build — rather than left to "be careful".

Hence the consequence usually asked next: a value in a map cannot be changed in place. m[k].field = 1 does not compile for a struct; you have to take the value, change it and put it back — or store pointers in the map.

Next they ask

If a map never shrinks, how do you clean a long-lived cache?

Short answer

By rebuilding it. delete frees the slot for a future entry but does not shrink the table, and neither does clear: the measurement gives 4618 KB after deleting all two hundred thousand keys against 4617 before.

The usual technique is to keep two maps and switch periodically: the new one fills while the old one goes to the collector whole. Another is to cap the growth up front, because a map that once reached peak load stays that size for the life of the process.

Next they ask

Why does a miss cost more than a hit — surely it is the other way round?

Short answer

It would be, if a lookup could say "not here" immediately. It cannot: the only sign of absence is a free slot encountered, because had the key existed it would lie no further than the first free place.

A hit stops earlier — on the key it found. A miss has to reach a free slot, and by construction those are scarce: the map runs at a load factor of 7/8. Measured: 28.96 ns against 19.80, a factor of 1.46.

Next they ask

Can keys be chosen so that a map degenerates into a list?

Short answer

In theory yes — if every key lands in one group, lookup becomes O(n). In practice not from outside: the map's hash is seeded with a random number at process start, so identical keys hash differently in different runs.

This is protection against an algorithmic attack, not decoration: before seeding existed, "send a hundred thousand keys with the same hash" took down web servers in other languages. A side effect of the seeding is the very unpredictability of iteration order.

Next they ask

When should you take sync.Map over a map with a mutex?

Short answer

When the profile matches what it was built for: keys written once and then read many times, or different goroutines working on non-overlapping key sets. Inside it holds two maps — a "clean" one for lock-free reads and a "dirty" one for writes — and the gain comes from reads not contending.

On an even mix of reads and writes it loses to an ordinary map under a mutex: every write also services the synchronization of two maps. "Always take sync.Map, it is thread-safe" is not the expected answer.

Common misconceptions

Claim

a map grows when it fills up

Actually

In the current implementation (go1.24) growth happens at a load factor of 7/8, and an eighth of the slots is kept empty on purpose: a lookup stops only on meeting a free slot, and without free slots a miss would have to be searched across the whole table. Measured: growths at sizes 9, 15, 29, 57, 113, 225, 449 — exactly "seven eighths of capacity plus one".

Claim

a miss is faster than a hit — it found nothing, after all

Actually

The opposite. A hit stops on the key it found; a miss must walk until it meets a group with a free slot, which is the only sign of absence. Measured on a hundred thousand entries: 28.96 ns against 19.80, a factor of 1.46 on the machine the run happened on; what carries over is the sign, not the ratio.

Claim

map iteration order is random

Actually

The contract says only one thing: the order is not guaranteed. The observable implementation of that is not a shuffle but a rotation: iteration begins at a random point and then walks the table in order. There are as many distinct orders as entries, not n!. That is more dangerous than real randomness: code depending on order would fail in half of runs under shuffling — under rotation it passes tests and breaks in production.

Claim

delete frees the memory a map occupies

Actually

It does not, and neither does clear. Measured on two hundred thousand entries: 4617 KB before, 4618 KB after every key was deleted, 4618 after clear(). A map in Go does not shrink — the table stays the size it grew to. Memory comes back only with a new map.

Claim

a size hint in make makes the map more compact

Actually

It barely changes the finished map: 4617 KB against 4617. What it changes is the work along the way — without the hint the runtime allocates and discards every intermediate table. Measured: 1044 allocations and 9236 KB against 514 and 4618 KB. What is saved is the garbage collector, not the map's memory.

Claim

a concurrent write panics, and the panic can be caught

Actually

It is a fatal error, not a panic: the stack is not unwound, deferred calls do not run, recover does not fire. That is deliberate — the Go memory model permits an implementation to stop the program on a race, because races on multiword data structures can lead to inconsistent values, up to memory corruption.

Claim

sync.Map is just a thread-safe map, so use that

Actually

It is tuned for a narrow profile: a key written once and read many times, or non-overlapping key sets across goroutines. On an even mix of reads and writes an ordinary map under a mutex beats it, because every write to a sync.Map also services the synchronization of two internal maps.

Claim

if a type is comparable it works as a key, no caveats

Actually

float64 is comparable — and still breaks on NaN, because NaN != NaN. Two writes under one NaN give len == 2, a lookup returns the zero value, and delete removes nothing. Entries that can be neither read nor deleted; the only way to be rid of them is together with the map.

Practice

Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from a script run rather than assigned.

Practice · predict the output

The key is NaN. We write under it twice, then read and delete. What does this code print?
nan := math.NaN()
m := map[float64]int{}
m[nan] = 1
m[nan] = 2
fmt.Println(len(m))
fmt.Println(m[nan])
delete(m, nan)
fmt.Println(len(m))

Practice · estimate

A map of a hundred thousand entries. How many times more does a miss cost than a hit?
times

Knowledge check

Question 1 of 6

A function takes a map[string]int and does m["a"] = 1, returning nothing. Does the caller see the write?

Sources & further reading

3 SOURCES

  1. The Go Programming Language Specification — Map typesOfficial documentation. The definition: “A map is an unordered group of elements of one type, called the element type, indexed by a set of unique keys of another type, called the key type”. The requirement on the key, and the one place it is checked at run time: “The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice. If the key type is an interface type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic”. And on nil: “A nil map is equivalent to an empty map except that no elements may be added”.https://go.dev/ref/spec
  2. The Go Memory ModelOfficial documentation. The rule about concurrent access lives here rather than in the language specification. The runtime's licence to stop the program: “An implementation may always react to a data race by reporting the race and terminating the program”. And the reason stopping was chosen for maps rather than corrupting data: “races on multiword data structures can lead to inconsistent values not corresponding to a single write … such races can in turn lead to arbitrary memory corruption”.https://go.dev/ref/mem
  3. internal/runtime/maps and runtime/panic.go at go1.24.7Go source code. The check all three concurrent-access messages grow from: `if m.writing != 0 { fatal("concurrent map writes") }` in `runtime_mapassign`. Why this is not a panic is stated in the comment on `fatal` itself in `runtime/panic.go`: “fatal is equivalent to throw, but is used when user code is expected to be at fault for the failure, such as racing map writes”, and next to it on `fatalthrow`: “implements an unrecoverable runtime throw”.https://github.com/golang/go/blob/go1.24.7/src/runtime/panic.go