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.
- 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.
- hashing, slot groups, the control byte, the load factor, Swiss tables;
- how a
fatal errordiffers from a panic, whatsync.Mapis, 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:
- "What does
m[k]return if the key is not there?" — the warm-up, which filters out everyone who expects an error. - "And how do you tell an absent key from a zero value?" — this is where the content starts.
- "What happens if two goroutines write to it?" — checking whether you know this is not a panic.
- "How does the lookup work?" — whether you name the hash and the group of slots or stop at the words "hash table".
- "What happens when the map grows?" — whether you know the threshold and can name it as a number.
- "What is the complexity?" — whether you separate the average from the worst case.
- "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.
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:
func fill(m map[string]int) { m["a"] = 1 } // visible outsideThe 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
.
var m map[string]int
fmt.Println(m["missing"], len(m)) // 0 0 — works
m["present"] = 1 // panic: assignment to entry in nil mapThe 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:
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 nothingEntries 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.
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.
The prediction from the stopping rule holds. Measured on a map of a hundred thousand entries:
| time | |
|---|---|
| hit | 19.80 ns |
| miss | 28.96 ns |
| ratio | 1.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:
| state | occupied |
|---|---|
| map full | 4617 KB |
after delete of every key, len = 0 | 4618 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:
| allocations | total allocated | final size | |
|---|---|---|---|
make(map[int]int) | 1044 | 9236 KB | 4617 KB |
make(map[int]int, 200000) | 514 | 4618 KB | 4617 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.
"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
If a map is a pointer, why is make needed at all?
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".
Why can't you take the address of a map element?
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.
If a map never shrinks, how do you clean a long-lived cache?
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.
Why does a miss cost more than a hit — surely it is the other way round?
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.
Can keys be chosen so that a map degenerates into a list?
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.
When should you take sync.Map over a map with a mutex?
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
a map grows when it fills up
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".
a miss is faster than a hit — it found nothing, after all
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.
map iteration order is random
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.
delete frees the memory a map occupies
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.
a size hint in make makes the map more compact
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.
a concurrent write panics, and the panic can be caught
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.
sync.Map is just a thread-safe map, so use that
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.
if a type is comparable it works as a key, no caveats
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
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
Knowledge check
A function takes a map[string]int and does m["a"] = 1, returning nothing. Does the caller see the write?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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 inmap[any]T, where the check moves to run time. A concurrent write is afatal error, not a panic:recoverwill not help, the stack is not unwound and deferred calls do not run. And a variable of typemapholds 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
deletenorclearshrinks the table.
In fact
- 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".
- 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.
- 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.
- It does not, and neither does
clear. Measured on two hundred thousand entries: 4617 KB before, 4618 KB after every key was deleted, 4618 afterclear(). A map in Go does not shrink — the table stays the size it grew to. Memory comes back only with a new map. - 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.
- It is a
fatal error, not a panic: the stack is not unwound, deferred calls do not run,recoverdoes 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. - 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.Mapalso services the synchronization of two internal maps. float64is comparable — and still breaks onNaN, becauseNaN != NaN. Two writes under one NaN givelen == 2, a lookup returns the zero value, anddeleteremoves nothing. Entries that can be neither read nor deleted; the only way to be rid of them is together with the map.
What is covered
- What is actually being asked
- Base: what a map does
- Mechanism 1: what a map promises
- Mechanism 2: maps and goroutines
- Mechanism 3: why changes are visible after passing to a function
- Mechanism 4: edge cases that cost a crash
- Deeper: how a map is built inside
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
3 SOURCES
- 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
- 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
- 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