Deep Engineering
Advanced·Published·60 MIN

Maps in Go: the language contract, the Swiss Table, and four consequences of the design

Three parts and one order: first what a map promises as a language construct — a zero value instead of an error, the two-result form, an unspecified iteration order and synchronisation; then how lookup is built in Go 1.24 — a fingerprint, a group of eight and the stopping rule; and only then four consequences with measurements: a miss costs more than a hit, the order is rotated rather than shuffled, an element's address cannot be taken, and len(m) == 0 does not mean the memory came back.

Full technical treatment

TL;DR

First, what the language guarantees. map[K]V is a built-in type for storing values by key. Reading a missing key returns the zero value of the element type rather than an error, so presence is checked with the v, ok := m[k] form. The iteration order is unspecified. The key must be comparable. Concurrent access with a write requires synchronisation — a rule absent from the map section of the spec entirely; it lives in the memory model.

Then how that is implemented in Go 1.24. The implementation moved to a Swiss Table: the hash splits into H1 (where to look) and a seven-bit fingerprint H2 (whom to check), entries lie in groups of eight, and a control word rules out candidates in one operation. The unit of everything here is the table: of lookup, of iteration, of growth and of memory.

And only then the consequences, each with a measurement.

  • Lookup stops at a group with a free slot, and a deleted slot does not stop it. Hence: a miss costs more than a hit — 28.62 ns against 17.83.
  • Iteration walks the table in order; only the starting offset is random. Hence: the observed order is rotated, not shuffled, and code that depends on the order will pass the tests and break in production.
  • Growth rebuilds the table whole and the entries move. Hence: an element's address cannot be taken.
  • Memory is released by tables too. Hence: len(m) == 0 does not mean the memory came back.

And the boundaries. The Swiss Table design is an implementation detail of one runtime version. Application code must rely on the guarantees of the specification, and the numbers below must be read within the bounds of go1.24.7, this machine and this shape of map.

The article has three parts, and their order is not accidental. First, what a map promises as a language construct: that is enough to write correct code, and there is not a word about internals in it. Then how lookup is built in Go 1.24. And only after that, the consequences that follow from the design — the ones that would otherwise have to be memorised.

Part I. What the language guarantees

Everything in this part is contract: it holds in any version of Go and does not depend on how a map is built inside.

A map in five minutes

Six operations, and there are no others:

GO
scores := map[string]int{
    "alice": 10,
    "bob":   7,
}                                   // create
 
scores["mike"] = 12                 // write
score := scores["alice"]            // read
score, ok := scores["john"]         // read, checking presence
delete(scores, "bob")               // delete
n := len(scores)                    // how many entries
 
for name, s := range scores {       // iterate
    fmt.Println(name, s)
}

The specification defines a map like this:

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 Go specification — Map types

The word unordered here is not about the order being random but about there being no such notion: a map promises no order, so there is nothing to rely on.

Of the key type the specification demands one thing — comparability:

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.

The Go specification — Map types

Numbers, strings, booleans, pointers, channels, and also structs and arrays made of comparable parts will do. A slice, a map and a function will not, and that is a compile error. An interface will, with a caveat we return to in the edge cases.

And the nil map: its zero value is not an empty map but "there is no map".

GO
var m map[string]int   // nil
fmt.Println(len(m))    // 0
fmt.Println(m["a"])    // 0
delete(m, "a")         // does nothing
for range m { }        // zero iterations
m["a"] = 1             // panic: assignment to entry in nil map

A nil map is equivalent to an empty map except that no elements may be added.

The Go specification — Map types

There is exactly one asymmetry here: only a write panics. So a forgotten make survives until the first insert — and fails somewhere other than where the mistake is.

A zero instead of an error: why the two-result form exists

Reading a missing key neither panics nor returns an error — it gives the zero value of the element type: 0 for an int, an empty string for a string, nil for a pointer.

Hence the question that is the whole point of this section:

How do you tell an existing key whose value is 0 from a missing key?

With the single-result form, you cannot: it returns zero in both cases. Only the two-result form tells them apart.

The rule is simple: v := m[k] when zero and absence mean the same thing (counters, sums, accumulators). v, ok := m[k] when they do not (caches, configuration, "does this user have a setting").

We will measure the price of that form later, in part three: it turns out to be practically nil — but first it must be clear why the form exists at all.

Why changes survive a function call

This is the most common practical difference between a map and a slice, and it is observable from the program:

GO
func add(m map[string]int) { m["x"] = 1 }
 
func main() {
    m := make(map[string]int)
    add(m)
    fmt.Println(m["x"])   // 1
}

An append inside a function, meanwhile, is not visible outside:

GO
func grow(s []int) { s = append(s, 42) }   // NOT visible outside

This is better phrased through observable behaviour than through internals: passing a map to a function copies the map value, but that value keeps referring to the same data structure, so changes to elements are visible to the caller. A slice copies a header carrying the length, and the new length stays in the copy.

Three practical consequences follow, all of them from the contract rather than the implementation: a map need not be returned from a function for changes to arrive; a map cannot be "copied" by assignment — an explicit walk is needed; and a nil map passed into a function stays nil for the caller even if make is assigned to it inside.

How a map value is represented in the runtime is an implementation detail. It can be confirmed, but it should not carry an argument:

GO
unsafe.Sizeof(map[int]int{})   // 8 — one word
unsafe.Sizeof([]int{})         // 24 — three
unsafe.Sizeof("")              // 16 — two

What the language promises and what the implementation does

Control bytes, groups and measurements are coming, and they are easy to mistake for guarantees. The dividing line runs here, and it is worth holding to the end:

The language contract — always true, safe to build code on:

  • the key must be comparable;
  • a missing key gives the zero value, and the ok form reports presence;
  • the iteration order is unspecified;
  • a nil map reads but does not write;
  • concurrent access with a write requires synchronisation.

The runtime implementation — true for one version; it explains behaviour but does not guarantee it:

  • Swiss Table, groups of eight, H1 and H2, the control word;
  • probing, tombstones, the 7/8 load factor;
  • a directory of tables and a 1024-slot limit per table.

Measured observations — true for one machine, one version and one shape of data:

  • 28.62 ns against 17.83 on a miss and a hit;
  • nine iteration orders for a nine-entry map;
  • 36.0 MB not released after clear.

From here on, every number says which of the three levels it belongs to.

Maps and goroutines

This topic sits here rather than at the end because it is needed before anything else: getting it wrong costs more than not knowing a thing about Swiss Tables.

A map may be read from any number of goroutines at once. As soon as even one writer joins them, the program itself must synchronise the access.

The first thing worth knowing about that rule: it is not in the language specification. The section on maps defines the key type, the behaviour of a nil map and growth — about goroutines it says nothing. The rule lives in the memory model:

A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.

The Go Memory Model

A runtime crash is not a synchronisation mechanism

It is customary to say "you will get a fatal error", and from that people conclude that at least the bug is visible. That is wrong, and the difference is shown by a run of bench/gomap/concurrent.go in two modes.

Two goroutines writing — the runtime notices and kills the process:

fatal error: concurrent map writes

goroutine 8 [running]:
internal/runtime/maps.fatal(...)
	/usr/local/go1.24.7/src/runtime/panic.go:1058

The runtime checks a "write in progress" flag on entry to every operation, so there are three messages and they say what collided:

what happened at oncewhat the runtime prints
two writesfatal error: concurrent map writes
a read and a writefatal error: concurrent map read and map write
an iteration and a writefatal error: concurrent map iteration and map write

A goroutine reading while another writes — the same run, without the race detector:

the program printed only its own "finished" line and exited with code 0. No warning, no fatal error, nothing in the log. The very same code under the race detector:

==================
WARNING: DATA RACE
Write at 0x00c00009a0f0 by goroutine 7:
  runtime.mapassign_fast64()
Previous read at 0x00c00009a0f0 by goroutine 8:
  runtime.mapaccess1_fast64()

The point this measurement was made for: the runtime detecting concurrent access is not a contract but a check on luck. It fires when two goroutines happened to be inside the map at the same moment, and stays silent when they did not. Correctness is built on the memory model and on synchronisation, not on the hope of crashing.

And one more thing: recover does not help here. The run above has a defer with a recover in it — it printed nothing, because this is not a panic:

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

runtime/panic.go

The difference is not cosmetic: panic unwinds the stack, runs deferred calls and can be recovered; fatal does none of that — beside it in the same file stands fatalthrow, "an unrecoverable runtime throw".

Why the severity. A map is a multiword structure, and a race on it corrupts not a value but consistency:

This means that races on multiword data structures can lead to inconsistent values not corresponding to a single write. When the values depend on the consistency of internal (pointer, length) or (pointer, type) pairs, as can be the case for interface values, maps, slices, and strings in most Go implementations, such races can in turn lead to arbitrary memory corruption.

The Go Memory Model

Stopping immediately is not strictness for its own sake but trading memory corruption for a crash visible in the log. As the run above showed, though, it does not always happen — and it cannot be relied on.

Three working models

First — a map under a mutex. The most common and almost always right. sync.RWMutex when readers clearly outnumber writers; a plain sync.Mutex otherwise. The same run under the race detector, with a mutex, finds nothing at all: all 160,000 writes land, there is no fatal error and the detector reports no findings.

Second — ownership by one goroutine. The map lives inside one goroutine and the others talk to it by messages over a channel. The synchronisation comes from the shape of the program rather than from locks, and there are no races by construction.

Third — sync.Map, and only for its own cases. Its own documentation talks you out of it:

The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content.

The sync package — the Map type

Two cases are named: a cache that only grows, and disjoint sets of keys across goroutines. Everything else is a plain map with a mutex.

This has to be checked with the race detector

Neither the compiler nor a review will find a race on a map. -race will:

go test -race ./...
go run -race ./cmd/service

The detector slows the program several times over and raises its memory use, so it goes into tests and staging rather than production. More importantly, it finds a race only if one happened in that run. The memory model calls it exactly that — a reaction to something detected:

Any implementation can, upon detecting a data race, report the race and halt execution of the program. Implementations using ThreadSanitizer (accessed with "go build -race") do exactly this.

The Go Memory Model

A green run under -race means "no race showed up on these inputs", not "there is no race". So tests with concurrent access are worth writing so that the accesses actually overlap, rather than so that the code merely runs.

Part II. How lookup is built

From here to the end of part two is the Go 1.24 implementation. Everything here explains behaviour and guarantees nothing: in another runtime version the design may differ, while the contract from part one will not.

Why a map needs a hash: a fingerprint and a full comparison

The general idea is the same in every hash table, and it is not about Go.

The key goes through a hash function and produces a number. Part of that number says where to look — which region of the table to inspect. The trouble is that different keys land in the same region, so finding the place is not enough: you must also work out whether the right key is there.

The naive answer is to compare keys in full. It is correct and expensive: comparing a string or a struct costs noticeably more than comparing two numbers.

Hence the trick a Swiss Table rests on: a second, short part of the hash is kept beside the slot as a fingerprint. A fingerprint does not answer "this key" — it is short, and collisions across different keys are inevitable. But a non-match rules the slot out for certain, and the full comparison falls to a handful of candidates:

Everything that follows is Go's concrete answer to four questions: how to get a region out of the hash, how many slots to check at once, how many bits to give the fingerprint, and when to stop looking.

These improvements include a new builtin map implementation based on Swiss Tables, more efficient memory allocation of small objects, and a new runtime-internal mutex implementation.

Go 1.24 release notes

The table is an array of groups: where the index comes from

Before taking a group apart, we have to say what a table is — otherwise the word "group" stays an abstraction and "the hash picks a group" stays an incantation.

A group is eight slots and a control word alongside them. A table is an array of such groups, laid out end to end and numbered from zero. There is nothing else in it:

group 0   [control word: 8 bytes][slot 0][slot 1] … [slot 7]
group 1   [control word: 8 bytes][slot 0][slot 1] … [slot 7]
…
group 7   [control word: 8 bytes][slot 0][slot 1] … [slot 7]

That answers what "H1 named the group" means. It did not find the key, did not choose a slot and has not looked anywhere yet: it produced an index into that array — a number from 0 to N−1. The search starts afterwards, inside the named group, over its control word.

How H1 becomes an index

The number of groups in a table is always a power of two, and that decision pays for itself right here:

h1    = h >> 7            drop the 7 fingerprint bits
index = h1 & (N − 1)      take the low bits of what is left

The second line is the remainder of a division by the number of groups, written as a single AND. That works precisely because N is a power of two: for any other number & (N − 1) stops being a remainder.

It is best seen in bits. Here are the low 16 bits of the hash of four keys with eight groups, cut twice — the fingerprint on the right, the index next to it:

keylow 16 bits of the hashgroup indexfingerprint
k0001000 110 000111060x0e
k1001001 001 100000110x41
k2000111 111 010100070x28
k7001010 000 010011100x27

The middle three bits are the index: it is not computed, it is taken. The seven on the right are the fingerprint, bound for the control byte of whichever slot the key lands in. The six on the left are the rest of H1: they take no part in the index, but they do take part in the probe path if the named group turns out to be full.

Why the index comes from H1 and not from the whole hash

So that the index bits and the fingerprint bits do not overlap. The cost of overlapping can be counted. Take the index from the low bits — the very ones the fingerprint already occupies — and see how many distinct fingerprints can then occur inside a single group. Measured over 400,000 keys with eight groups:

  • index from H1, as in Go — all 128 fingerprint values occur in a group;
  • index from the low bits — 16.

The fingerprint loses three bits, false positives during lookup become roughly eight times more frequent, and each one is paid for with a full key comparison. Splitting the hash into non-overlapping parts is not tidiness; it is the condition under which the fingerprint filters anything at all.

A key has no group "of its own"

Without this, everything above misleads. The index is a function of the hash and of the table's current size. A key's hash never changes; the mask changes at every growth:

key4 groups8163264
k026666
k111999
k237153163
k70001616

Doubling adds one bit to the mask, and that bit either leaves the key where it was or moves it forward by exactly the previous number of groups: k1 goes 1 → 9, k7 goes 0 → 16. Across 100,000 keys about half change index: 50.07% going from 4 groups to 8, and 49.97% from 32 to 64. Consequence three from part three follows from this: growth rebuilds the whole table, and so you cannot take the address of a map element.

Below, the whole path is played out step by step — from the 64-bit hash to a highlighted cell of the array — and the second tab shows the same unchanged hash landing on different indexes as the table grows:

Anatomy of a group: eight slots, eight bytes and three states

A hash table always has the same job: given a key, find its place fast. The hash gives a number, the number gives a position — but sooner or later two different keys give the same position, and something has to happen next.

The old Go map answered with chains: a position led to a "bucket" of eight pairs, and if that overflowed, to the next bucket by a link. A Swiss Table answers differently, and its whole design serves one goal: make eight candidates testable with one comparison rather than a loop of eight.

For that, a short summary of the eight slots lies next to them. The key's hash splits into two unequal parts:

  • the upper 57 bits (H1) choose which group the search starts at;
  • the lower 7 bits (H2) are written into a separate byte — the slot's tag.

What H1 does is unpacked in the section above: its low bits are the index of the group the search starts at. What matters here is the other half: that is where its work ends. H1 is not stored, is not written into any byte and is compared against nothing — what goes into the control byte is H2. The moment at which H1 drops out shows up not in the anatomy of a group but across a whole operation, and is unpacked below, on an insert.

The terms come from the runtime itself:

Group: A group of abi.SwissMapGroupSlots (8) slots, plus a control word. H1: Upper 57 bits of a hash. H2: Lower 7 bits of a hash.

internal/runtime/maps/map.go

Here is how that lies in memory. A control byte and a slot are one position, not two separate places: eight bytes run in a row, and behind them eight key–value pairs. Click the slots — below the diagram it says what each state means and what a lookup does on reaching it:

Now the part that usually gets skipped — and without which the mechanism does not come together. A byte has eight bits, a tag has seven. Where did the eighth bit go?

It is spent on letting one byte distinguish three states of a slot. The patterns are written out in the source verbatim:

  empty    1 0 0 0 0 0 0 0
  deleted  1 1 1 1 1 1 1 0
  full     0 h h h h h h h     ← the seven tag bits

The top bit is the occupancy flag, and an inverted one at that: for a full slot it is clear. That single convention yields three separate tests, each of them one operation on the same bit:

  • the slot is full — the top bit is clear;
  • the slot is empty or deleted — the top bit is set;
  • the slot is empty — the top bit is set and the second-from-bottom is not (a deleted slot has it set, an empty one does not).

So the tag is seven bits not because "seven is enough" but because the eighth is given to the state. That price is what makes everything else possible.

How eight comparisons become one

The eight control bytes lie next to each other in memory and make up a single 64-bit number — the control word. Usually the explanation stops at "compares eight slots in one operation", and that sentence cannot be understood until you see what happens to the bytes. Here is what happens.

Step one — replicate the tag. The seven wanted bits are copied into all eight bytes, by multiplying by 0x0101010101010101. Now the comparison is not against a slot but against the whole word at once.

Step two — XOR. A byte becomes zero exactly where the two bytes matched. "Find the matching slots" has become "find the zero bytes", and that one is already solvable with arithmetic.

Step three — catch a zero byte with a borrow. One is subtracted from every byte: v - 0x0101010101010101. A zero byte has nothing to subtract from — it borrows and becomes 0xFF, so its top bit lights up. A non-zero byte does not light up this way; and if its top bit was set before the subtraction, the &^ v part clears it.

Step four — keep one bit per slot: & 0x8080808080808080. That is the finished answer: eight bits, one per slot.

In one line, this is the body of the runtime's function:

GO
v := uint64(g) ^ (bitsetLSB * uint64(h))
return bitset(((v - bitsetLSB) &^ v) & bitsetMSB)

Below, the same four steps run on real bytes — switch steps and tags. Every row of bits is printed by bench/gomap/controlword.go, which runs these formulas and cross-checks them against a plain eight-byte loop:

Three arithmetic operations on one number instead of a loop of eight comparisons. On amd64 one SIMD instruction does the same three steps, and the formula above is the portable variant for machines that have no such instruction.

Now for what a tag is not. Seven bits are 128 values, so two different keys sharing a tag is an everyday event. A tag answers not "this key" but "maybe this one": after a match the key is compared in full. A non-match, though, rules the slot out for certain, and that is where the saving is — the full comparison falls to one or two slots out of eight rather than all eight.

How much that imprecision costs is estimated in the runtime itself:

The expected number of objects with an h2 match is then k/128. Measurements and analysis indicate that even at high load factors, k is less than 32, meaning that the number of false positive comparisons we must perform is less than 1/8 per find.

internal/runtime/maps/table.go

There is a second, rarer imprecision — in the formula itself. The borrow travels from a lower byte into a higher one, so once in a while an extra bit lights up. The comment in group.go names a concrete case: for the word 0x0302, searching for the tag 0x02, the formula reports slots 0 and 1 while an honest loop reports only slot 0. This costs no correctness precisely because a tag is not an answer anyway: an extra candidate costs one key comparison. The run of bench/gomap/controlword.go over two hundred thousand random groups confirms it: real matches missed — zero, extra ones reported — 0.03%.

The stopping rule: a free slot against a deleted one

The group is not always the one the hash chose: if there is no room in it, the search moves to the next one along the path. That path is neither random nor "just the next one" — the offset grows triangularly, p(i) = (i² + i)/2 + H1, and when the group count is a power of two such a sequence visits every group exactly once.

So a sign is needed for ending the search and saying "the key is not here". There is exactly one:

Probing stops when it finds a group with an empty slot.

internal/runtime/maps/map.go

The logic is simple: had the key existed, an insert would have put it no further than the first free spot. Reach a free slot and there is no point looking further.

The subtlety is in the word free. A deleted slot does not count as free, and the source explains why:

When deleting from a completely full group, we must not mark the slot as empty, as there could be more slots used later in a probe sequence and this deletion would cause probing to stop too early.

ibid.

This is what the byte's third state is for. A deleted slot is free for an insert but occupied for a lookup: an insert asks "empty or deleted", a lookup asks "empty". One bit tells the first apart, two bits tell the second, and both tests stay one operation on the word.

And the last piece of the picture: free slots must exist. Fill the table to the brim and a lookup has nowhere to stop, so a miss would have to search the whole table. That is why the map grows without waiting to be full: the threshold is 7/8. An eighth of the slots is kept empty on purpose, and it is not a safety margin but the price paid for the stopping rule.

An insert end to end: what H1 does, and what H2 does

Where the group index comes from is unpacked above; so is how many bits the fingerprint has and why the eighth is spent on state. One thing is left that neither section shows on its own: why there are two halves at all, and what the division of labour buys. The difference between H1 and H2 is not in what they look like — both are just pieces of one number — but in when they act: the first works once, before the table is looked at at all; the second works at every step and then stays in memory. That is visible only across a whole operation. Take an insert.

The claim everything else follows from:

An insert is a lookup that failed. Not "found a free spot and put it there". The map is obliged to establish that the key is absent first, and only then take a slot. Otherwise the same line m["a"] = 1 run twice would produce two entries.

Here is the whole path, step by step.

Step 0. The hash is cut at the seventh bit. h1 = h >> 7, h2 = h & 0x7f. No bit plays both roles: a tag match says nothing about the group, and a group number says nothing about the tag.

Step 1. H1 is applied — exactly once. h1 & (number of groups − 1) gives the starting group, and it also seeds the triangular probe sequence. That is all; H1 takes part in no comparison after that. It is not stored, not checked, and past this step it is not needed.

Step 2. H2 enters — and acts at every step. In each group along the path, the group's control word is compared against the tag in a single operation. It is also the only part that stays in memory: when the key lands in a slot, the control byte gets H2.

Step 3. Inside a group, the search is for the KEY, not for a spot. A tag match produces candidates, and every candidate is checked by a full key comparison. If one matches, the value is overwritten in place: no new slot appears, the control byte does not change, len stays as it was. Insert and update are the same operation, parting ways on the last comparison.

Step 4. The key is not in this group — is this the end of the path? The question from the previous section. The group is completely full — step to the next one. There is a free slot — the path is over, the key is definitely not in the table. But if the first non-occupied slot is a deleted one, the spot is remembered and the path continues.

Step 5. The write. If a deleted slot was remembered along the way, the key goes into it rather than where the path stopped — and it costs nothing from the growth budget, having already been accounted for. If there is no growth budget left, the table is rebuilt first.

Step five is the least obvious and the most useful. The price follows from it:

Inserting a new key costs a full miss lookup. Not "found a hole and filled it" — until a free slot turns up, there is no telling whether the key is further along the path, so the first available spot cannot simply be taken.

Below, those same five scenarios are played out step by step. Two things are worth watching: the moment H1 stops taking part, and which slot the write actually goes to in the last scenario:

One detail remains, and it is the one that makes these two halves easiest to confuse. In group.go the pattern for an occupied slot is annotated like this:

full: 0 h h h h h h h  // h represents the H1 hash bits

The comment says H1 — while the code beside it writes H2 into the control byte. Both lines come from the same package:

GO
seq := makeProbeSeq(h1(hash), t.groups.lengthMask) // H1 sets the path
g.ctrls().set(i, ctrl(h2(hash)))                   // H2 goes into the byte

And above the h2 function itself, map.go says it outright:

Extracts the H2 portion of a hash: the 7 bits not used for h1. These are used as an occupied control byte.

internal/runtime/maps/map.go

So the control byte holds H2; the comment in group.go has drifted from the code. Checked on go1.24.7 — and it is worth keeping in mind while reading the sources: the names H1 and H2 come from Abseil, and so does the confusion in that comment.

The design is now described whole: table → groups of eight → H1 picks the starting group → the H2 tag in the control word → one operation instead of eight comparisons → stopping at a free slot → a 7/8 load factor. Four consequences follow, each checked by measurement.

Part III. Consequences of the design

Everything from here on follows from part two, and each item is backed by a measurement. The numbers here belong to the third level of the split above: one machine, one version, one shape of map. What is stable in them is not the value but the direction.

Consequence one (lookup): a miss costs more than a hit

A hit stops on the key it found — on average in the very first group. A miss has to walk to a group with a free slot, and by construction those are scarce: the load factor is 7/8.

Measured on a map of a hundred thousand entries:

time
hit17.83 ns
miss28.62 ns
ratio1.6

That is the exact opposite of the expectation — "a miss found nothing, so it must be faster". The mechanism says otherwise, and the practical consequence is direct: if _, ok := m[k]; !ok costs more than reading an existing key, and in a hot loop where misses are frequent that shows.

The same cause is visible against the previous implementation

Go 1.24 makes an honest comparison possible — the old implementation is still there behind a build flag:

The new builtin map implementation and new runtime-internal mutex may be disabled by setting GOEXPERIMENT=noswissmap and GOEXPERIMENT=nospinbitmutex at build time respectively.

Go 1.24 release notes

A rare opportunity: one compiler, one machine, one benchmark, exactly one build flag different. The runs were interleaved — on a virtual machine the clock drifts, and two consecutive runs would show a difference that is not there.

operationSwiss Tableold
hit, int64, v, ok := form17.95–18.8835.22–36.58new is twice as fast
hit, string25.59–28.1451.89–54.70new is twice as fast
miss, int6428.96–29.6620.57–21.71new is 1.4 times slower
iterating 100,000 entries847,706–893,8511,046,614–1,079,682new is ~25 % faster

The ranges across four rounds do not overlap on any row — this is not noise.

The miss row is explained by the same stopping rule, seen from the other side. In the old map a miss looked at one bucket — eight tophash bytes — and ended there if no overflow chain existed. In a Swiss Table a miss must walk the probe sequence to a free slot. What makes a hit fast makes a miss long.

The Go team's blog does make the caveat about regressions, without naming a case:

Some edge cases do regress compared to Go 1.23.

Faster Go maps with Swiss Tables

Here is one such case, and it does not look like an edge: checking whether a key exists is an ordinary operation. A caveat is owed to the measurement too: this is one shape of map (map[int64]int, a hundred thousand entries, consecutive keys), and the cost of a miss depends on the load factor and on how the keys are laid out.

Consequence two (iteration): the only rule is not to depend on the order

From this whole section, one sentence goes into your work, and here it is:

Code must not depend on a map's iteration order. A map promises no order, and the observed order is not a contract.

Everything below is a study of the implementation. It explains why buggy code can go unbroken for a long time, and that is its whole value: the bug does not become less of a bug for it.

That iteration order is unspecified is common knowledge. The runtime goes further than not guaranteeing it — it randomizes deliberately:

Iteration order is unspecified. In the implementation, it is explicitly randomized.

internal/runtime/maps/map.go

Implementation detail: why buggy code can go unbroken for a long time

What goes unsaid is how it is randomized. And here the table comes in again: iteration walks it in order, and only the starting point is random.

One and the same nine-entry map, iterated two thousand times, produces exactly nine distinct orders. Not 362,880. And all nine are rotations of one sequence. The reason is a single line in the iterator:

GO
entryIdx := (it.entryIdx + it.entryOffset) & entryMask

entryOffset is taken once, when the iterator is created. After that the slots are walked in order. So the sequence is one and only its shift is random. The sequence itself differs per program run — the map seeds its hash at startup; what is constant is that within one run the orders differ only by a shift.

Why that is more dangerous than real randomness. Code that accidentally depends on key A coming before key B would, under real shuffling, fail in roughly half the runs and be caught on day one. Under rotation it fails only on the shifts that cut the tape between those two keys — and the closer the keys lie, the fewer those are. So it passes the tests, passes review, and breaks in production when the map changes slightly.

The rule's boundary is computed from constants — the same table again

Everything above holds while the map fits in one table. The table's limit is maxTableCapacity = 1024, and it fills to 7/8, that is to 896 entries. Beyond that the map splits into several tables, the iterator gains a second independent offset — over the table directory (it.dirOffset) — there is more than one tape, and they are shuffled among themselves.

Measured exactly where the constants predict: at 896 entries there are 896 distinct orders, at 897 already 1174.

That is a good check that the mechanism was understood correctly: the boundary was not found experimentally, it was computed from two runtime constants and then confirmed by running it.

The practical conclusion is the old one: if you need an order, collect the keys and sort them. slices.Sorted(maps.Keys(m)) does it in one line.

Consequence three (growth): the entries move

When the load factor reaches 7/8 the table is not "extended" — it is rebuilt at twice the size and every entry is laid out afresh. Two practical consequences follow, usually learned separately.

An element's address cannot be taken.

GO
p := &m["a"]        // cannot take the address of m["a"]
m["a"].field = 2    // cannot assign to struct field

This is not a compiler whim: a pointer taken before a growth would lead into somebody else's memory. The ban is at compile time rather than left to "be careful". The way around it is map[K]*V: then the pointer moves and the object stays put.

And a caveat back to the iteration section. A map larger than maxTableCapacity = 1024 is stored not as one table but as several — and then it grows one table at a time: only the entries of the table that overflowed are moved. The table remains the unit of growth here too; what changes is that there is now more than one of them. That is exactly why the boundary of the iteration-order rule falls at 896 entries.

A size hint removes not bytes in the map but the intermediate tables. Here the expectation is disappointed in the other direction. A million entries:

total allocatedallocationsstill live
make(map[int]int)75,407,864 B8,18837,776,744 B
make(map[int]int, n)37,832,960 B4,10137,832,752 B
ratio×1.99×2.00×1.00

The finished map is the same — that is the last column. What is saved is not bytes inside it but the tables thrown away along the way: without a hint the map grows by doubling, and the sum of all intermediate tables is roughly the size of the final one. Hence exactly twice.

So a hint helps the garbage collector's workload, not memory consumption. If you were told otherwise, now there is something to check it with.

Consequence four (memory): the table does not shrink

Growth goes by tables — and so does release. A million map[int]int entries, live heap:

a million entries:                36.0 MB,  len = 1,000,000
after deleting every key:         36.0 MB,  len = 0
after clear(m):                   36.0 MB,  len = 0
after m = make(map[int]int):       0.1 MB

Neither delete nor clear gives memory back: the table stays the same size, just empty. Only replacing the map itself frees it.

This is not an optimization but the difference between a working service and a leak. A cache that lives long and is cleaned on a schedule will hold memory at its historical maximum — forever.

There is exactly one cure: m = make(map[K]V) instead of clear(m) when cleaning means "start from nothing".

That is the end of the four consequences of the design. What follows are two topics that do not derive from it and live by their own rules.

What this means when choosing

The design is covered; what remains are practical prices that do not follow from it directly but are needed when choosing between options.

The comma-ok form costs nothing. v, ok := m[k] — 18.43 ns against 17.83 for v := m[k]; on a miss 28.44 against 28.62, that is, in the other direction. The difference is inside the run-to-run spread, and both numbers of each pair come from one block of measurements — otherwise comparing them would not be allowed. The compiler emits the same runtime call and simply takes the second return value in the second case.

A string key costs half again as much as an integer one — 28.19 against 18.06. That is not the map's doing: a string's hash is computed over its contents, while an int64's is computed over eight bytes. A composite [2]int64 key of the same width costs 24.95: the runtime has separate fast paths for integers and strings (map_fast64_swiss.go, map_faststr_swiss.go), while everything else goes through the general code.

If the keys are a dense range of integers, a map is not needed. One read by a key already at hand: 15.55 ns from a map against 1.12 from a slice. Those two numbers compare with each other but not with the 18.06 above: there the key was still being fetched from a slice of keys. Iterating a hundred thousand entries: 855,702 ns against 37,434. Fourteen times and twenty-three times.

Edge cases worth knowing

Everything below is rare, but every item of it has cost somebody a working day at least once. It adds nothing to the basic model of a map, and it is worth reading last.

The key contract: comparable does not mean reflexive

The specification states the requirement on the key type through operators:

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.

Go specification, Map types

The requirement looks exhaustive, but between "the operators are defined" and "the map behaves as expected" there is a gap, and three cases fall into it.

NaN: an entry that can be neither found nor deleted

float64 satisfies the requirement: the operators are defined for it. But definedness is not reflexivity, and a map looks a key up by == precisely:

Floating-point types are comparable and ordered. Two floating-point values are compared as defined by the IEEE 754 standard.

Go specification, Comparison operators

What comes of that is visible by running it (bench/gomap/nankey.go):

after m[nan] = 1 → len(m) = 1
after m[nan] = 2 → len(m) = 2
after m[nan] = 3 → len(m) = 3
after m[nan] = 4 → len(m) = 4
after m[nan] = 5 → len(m) = 5

m[nan]        → value 0, found false
len before delete: 5
len after two deletes: 5

Five assignments with the same variable as the key produced five entries. An ordinary key would have produced one: an assignment overwrites the key it found, and NaN cannot be found. The lookup does not find it; delete does not find it and stays silent, because there is nothing for it to remove. The entries are there all along: iteration sees them, they hold their values, they occupy memory. They are unreachable only by key.

This is not a side effect but known behaviour, written down in the runtime:

NOTE: Because NaN != NaN, a map can contain any number of (mostly useless) entries keyed with NaNs. To avoid long hash chains, we assign a random number as the hash value for a NaN.

runtime/alg.go

The runtime softens the consequence — handing NaN values a random hash so they do not gather into one chain — but does not remove it. And the map implementation states what can be done with such an entry at all:

One exception is keys that don't compare equal to themselves (e.g., NaN). These keys cannot be looked up, so getWithKey will fail even if the key exists. However, we are in luck because such keys cannot be updated and they cannot be deleted except with clear.

internal/runtime/maps/table.go

Verified: clear(m) removes them all and returns len to zero. There is no other supported way.

The practical conclusion. NaN cannot be written as a literal — the compiler rejects the constant 0.0/0.0 with invalid operation: division by zero. NaN arrives from data: dividing zero variables (var z float64; z/z), math.Sqrt(-1), parsing the string "NaN". A map whose keys are numbers from an external source grows on such values without limit, and not one of those entries is ever reused. They must be filtered at the entrance with math.IsNaN: afterwards they can only be removed along with the whole map.

And a control, so as not to over-generalize: on other numbers the map shows no strangeness. +0.0 and -0.0 are equal under IEEE 754, and the map honestly treats them as one key — the second insert overwrote the first, len stayed

  1. What breaks is exactly the place where == stops being reflexive, and that is only NaN.

An interface key defers the check to run time

map[any]T is the one place where a non-comparable key is not caught by the compiler:

GO
m := map[any]int{}
m[[]int{1}] = 1   // panic: runtime error: hash of unhashable type []int

And in the same place: int(1) and int64(1) are different keys, because the type takes part in the comparison.

A nil map: where exactly this fires

The rule itself is covered in part one: everything reads, only a write panics. What matters here is where it shows up in practice — and it always shows up late.

A struct field. A struct with an undeclared map inside is created without error, reads fine and fails on the first write — sometimes much later than it was created, and in another package. The cure is a constructor that makes every map in the struct.

A function's return. func load() map[string]int may well return nil on the error branch, and a caller that only reads will notice nothing. The trap springs on the next caller, who decides to add an entry.

An assignment inside a function. A func fill(m map[string]int) that does m = make(...) changes nothing for the caller: the map value is copied, and the new map stays in the copy. A map that must be created is returned.

A key larger than 128 bytes moves out of the slot

internal/abi sets SwissMapMaxKeyBytes = 128. Measured on ten thousand entries:

keyallocatedallocations
[128]byte2,359,984 B34
[136]byte1,735,600 B10,034

Two things happen past the threshold, and they point in opposite directions: there is now one allocation per every key, while the bytes go down. The second is not a typo: all eight slots in a group are reserved whether occupied or not, while a key moved out is allocated at exactly its size.

How to reproduce the numbers

The measurements are bench/gomap/layout.go (layout) and bench/gomap/cost_test.go (cost). bench/gomap/nankey.go measures no time at all: it prints len, lookup results and delete for a NaN key. Table growths are caught by bench/gomap/growth.go. The bit arithmetic of the control word is run step by step and cross-checked against a plain loop by bench/gomap/controlword.go — it measures no time either, it prints the bits themselves. The whole insert path is played out by bench/gomap/insert.go, and the split of a hash into a group index and a fingerprint by bench/gomap/groupindex.go; both port the runtime's rules line by line and check themselves rather than asking to be taken on trust. This is a separate Go module, so they run from inside it:

go run bench/gomap/layout.go          # works from the repo root too
go run bench/gomap/growth.go
go run bench/gomap/controlword.go
go run bench/gomap/insert.go
go run bench/gomap/groupindex.go
cd bench/gomap
go test -run '^$' -bench . -benchmem .
./ab.sh                               # the old implementation against the new

layout.go prints observations without a single time measurement. ab.sh alternates builds with and without GOEXPERIMENT=noswissmap and prints a cpu: line for each run: if it differs between rounds, the round should be discarded rather than counted.

Published run: go1.24.7 linux/amd64, Intel Xeon 2.10 GHz, August 2026. Times depend on the machine; the B/op and allocs/op columns do not.

Common misconceptions

Claim

A Go map is buckets of eight elements with an overflow chain

Actually

That was so before Go 1.24. Since 1.24 the builtin map is a Swiss Table: groups of eight slots, one 64-bit control word per group holding the low seven bits of each key's hash. The old implementation has not gone anywhere but is enabled by a build flag: GOEXPERIMENT=noswissmap. That is precisely how the two implementations could be compared on one machine.

Claim

Checking that a key is absent is cheaper than finding it

Actually

The other way round, and it follows directly from the design: Probing stops when it finds a group with an empty slot. A hit stops the moment the key is found; a miss must go on until it meets a group with a free slot, and at a load factor of 7/8 such groups are scarce. Measured on a hundred thousand entries: a miss takes 28.62 ns against 17.83 for a hit — 1.6 times over.

Claim

The new map implementation is faster than the old one

Actually

Not at everything. On a hit, twice as fast (17.95–18.88 against 35.22–36.58 ns); on iteration, about a quarter. But on a miss the new one is SLOWER than the old by 1.4 times: 28.96–29.66 against 20.57–21.71, and the ranges of four interleaved rounds do not overlap. The Go team's blog makes the caveat — Some edge cases do regress compared to Go 1.23 — but does not name the case. Checking whether a key is present does not look much like an edge case. The mechanism differs too: in the old map a miss looks at one bucket and ends there, while in a Swiss Table it walks on to a group with a free slot. One shape of map was measured: map[int64]int, 100,000 entries, consecutive keys.

Claim

The v, ok := m[k] form costs more than v := m[k]

Actually

It costs nothing. Measured within one block: a hit takes 18.43 against 17.83 ns, a miss 28.44 against 28.62 — the difference is inside the run-to-run spread, and on a miss it even goes the other way. The compiler emits the same runtime call and in the second case simply takes the second returned value. There is nothing to choose between the forms on speed; you choose by whether you need to tell “no key” from “present, but a zero value”.

Claim

Map iteration order is random

Actually

It is randomized but not shuffled. A nine-entry map iterated two thousand times produces exactly NINE distinct orders, not 362,880, and all nine are rotations of one and the same sequence. The rule holds up to 896 entries — that is maxTableCapacity (1024) times 7/8: beyond it the map no longer fits in one table, the iterator gains a second offset over the directory of tables, and at 897 entries there are already 1,174 orders. The iterator has entryIdx := (it.entryIdx + it.entryOffset) & entryMask, and entryOffset is drawn once for the whole iteration. In practice this is more dangerous than true randomness: code depending on the order of a pair of keys fails only on those shifts that cut the ribbon between them — that is, it passes the tests.

Claim

delete or clear(m) give memory back

Actually

Neither does. Measured: a million map[int]int entries take 36.0 MB of live heap; after deleting every key, the same 36.0 MB, and after clear(m) the same again. The table stays its previous size, merely empty. Memory is freed only by replacing the map itself: m = make(map[K]V). For a long-lived cache that is the difference between “the memory came back” and “it never did”.

Claim

make(map[K]V, n) makes the map smaller

Actually

The finished map comes out exactly the same size: 37,776,744 bytes against 37,832,752 — a difference in the per-mille. What halves is something else, what is allocated ON THE WAY: 75,407,864 bytes against 37,832,960, and 8,188 allocations against 4,101. Without a hint the map grows by doubling, and the sum of all the discarded tables roughly equals the final one. So the hint takes load off the garbage collector and leaves memory consumption where it was.

Claim

A large key makes the map heavier

Actually

Past the SwissMapMaxKeyBytes = 128 threshold it makes it lighter, and that is measurable. 10,000 entries with a [128]byte key: 2,359,984 bytes and 34 allocations. With a [136]byte key: 1,735,600 bytes and 10,034 allocations. Fewer bytes, because slots in a group are reserved all eight at once, whereas a key moved past the threshold is allocated at exactly its size. The price is paid elsewhere: one allocation per key instead of three dozen for the whole map.

Check yourself

Question 1 of 5

On a full map, which costs more: finding a key or establishing that it is absent?

Sources & further reading

9 SOURCES

  1. Go 1.24 Release Notes, Runtime sectionOfficial documentation. Where the change of implementation is announced: “These improvements include a new builtin map implementation based on Swiss Tables, more efficient memory allocation of small objects, and a new runtime-internal mutex implementation”. The same paragraph names the way back: “The new builtin map implementation and new runtime-internal mutex may be disabled by setting GOEXPERIMENT=noswissmap and GOEXPERIMENT=nospinbitmutex at build time respectively”. It is that flag which makes an honest comparison of the two implementations on one machine possible.https://go.dev/doc/go1.24
  2. Faster Go maps with Swiss Tables — the Go team's blogOfficial documentation. The design explained by the authors of the change: “Each group has a 64-bit control word for metadata. Each of the 8 bytes in the control word corresponds to one of the slots in the group”. The claimed gain: “map operations are up to 60% faster than in Go 1.23”, and beside it the honest caveat that makes the page worth reading to the end: “some edge cases do regress compared to Go 1.23”. Which ones is not said; one such case is measured in this article.https://go.dev/blog/swisstable
  3. internal/runtime/maps/map.go — the package's opening commentGo source code. The most detailed source on the design. The terms are defined there: “Group: A group of abi.SwissMapGroupSlots (8) slots, plus a control word” and “H1: Upper 57 bits of a hash. H2: Lower 7 bits of a hash”. From the same place, the stopping rule the whole cost of a miss grows out of: “Probing stops when it finds a group with an empty slot”, and the explanation of why a deleted slot does not count as free: “when deleting from a completely full group, we must not mark the slot as empty, as there could be more slots used later in a probe sequence and this deletion would cause probing to stop too early”. And on iteration, outright: “Iteration order is unspecified. In the implementation, it is explicitly randomized”.https://go.dev/src/internal/runtime/maps/map.go
  4. internal/runtime/maps/table.go — the iterator and the table size limitGo source code. The line that explains why a “random” order turns out to be a rotation: `entryIdx := (it.entryIdx + it.entryOffset) & entryMask`, where `it.entryOffset = rand()` is drawn once when the iterator is created. The comment beside it: “Randomize iteration order by starting iteration at a random slot offset”. From the same file, `const maxTableCapacity = 1024` with the authors' candid note: “TODO: Completely made up value. This should be tuned for performance vs grow latency”. The same file carries the NaN comment: "However, we are in luck because such keys cannot be updated and they cannot be deleted except with clear" — the only supported way to remove such an entry, named outright.https://go.dev/src/internal/runtime/maps/table.go
  5. 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 where 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
  6. The Go Memory ModelOfficial documentation. The rule about concurrent access lives here, not in the language specification — the section on map types says nothing about goroutines at all. The definition: “A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package”. What an implementation may do about it: “An implementation may always react to a data race by reporting the race and terminating the program”. And why stopping is the reaction chosen for maps: “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”. The same document on the detector: “Any implementation can, upon detecting a data race, report the race and halt execution of the program”.https://go.dev/ref/mem
  7. internal/runtime/maps/runtime_swiss.go and runtime/panic.goGo source code. The check all three messages grow out of: `if m.writing != 0 { fatal("concurrent map writes") }` in `runtime_mapassign`; the same flag is read by `runtime_mapaccess1` (“concurrent map read and map write”) and by the iterator in `table.go` (“concurrent map iteration and map write”). 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`: it “implements an unrecoverable runtime throw”.https://go.dev/src/internal/runtime/maps/runtime_swiss.go
  8. Package sync — type MapOfficial documentation. sync.Map arguing against itself, in its own documentation: “The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content”. The two cases it was built for: “(1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys”.https://pkg.go.dev/sync#Map
  9. runtime/alg.go — hashing floats, and NaNGo source code. The comment above `f32hash`/`f64hash`, where the accumulation of NaN entries is called known behaviour rather than a defect: "NOTE: Because NaN != NaN, a map can contain any number of (mostly useless) entries keyed with NaNs. To avoid long hash chains, we assign a random number as the hash value for a NaN." The same place shows how it is done: `case f != f: return c1 * (c0 ^ h ^ uintptr(rand()))`.https://go.dev/src/runtime/alg.go