BetterSWE DigestWhat is BetterSWE?

Deep Dive · Issue 9

Golang's Swiss Tables: How Go Quietly Replaced Its Map Internals

Go 1.24 swapped out the guts of every map without touching a line of your code. Here is the hash table that did it, and the tricks Go had to add to make it fit.

Old Go map bucket array with overflow chains leading to scattered memory

The problem

Every Go program spends 10% of its time in maps

Old Go map bucket array with overflow chains leading to scattered memory

When the Go team profiled a broad range of production applications, they found programs spending roughly 10-15% of all CPU time just on map operations. Optimize that one runtime structure and every Go program on earth gets a little faster for free. So in Go 1.24 they rewrote it.

The old design was a classic bucketed hash table. Each bucket held 8 key-value slots plus a small tophash array, and when a bucket filled up it grew an overflow pointer to a chained overflow bucket. That chain is the weak spot: following it means jumping to a fresh cache line, and hashes that miss their group turn into pointer-chasing across memory.

bucket ─▶ [ k k k k k k k k ]  8 slots + tophash
                            │
                     overflow ptr
                            ▼
              [ k k _ _ _ _ _ _ ]   ← cache miss
                            │
                     overflow ptr
                            ▼
              [ k _ _ _ _ _ _ _ ]   ← cache miss
Old design: a full bucket chains to overflow buckets scattered elsewhere in memory.

The external API never changes. make, indexing, delete, and range all behave exactly as before. What changed underneath is the whole strategy for finding a key, and Google's Swiss Table design is the starting point.

The core trick

Groups of 8, and one word that answers eight questions

A group of eight slots with an eight-byte control word above it

A Swiss Table keeps everything in one contiguous array, divided into groups of 8 slots. Alongside each group sits a 64-bit control word, one byte per slot. Each control byte says whether the slot is empty, deleted, or full, and if full it stores a 7-bit fingerprint of that slot's hash.

The 64-bit key hash is split in two. The upper 57 bits (h1) choose which group to probe. The lower 7 bits (h2) become the fingerprint written into the control byte. In Abseil's original encoding an empty slot is 0x80, a deleted slot 0xFE, and a full slot is 0x00-0x7F, the control bit plus that 7-bit hash.

key hash ┬─ h1 (57 bits) ─▶ pick group
         └─ h2 ( 7 bits) ─▶ fingerprint

 test  [ h2 h2 h2 h2 h2 h2 h2 h2 ]  broadcast ×8
 ctrl  [ E  F  F  E  F  D  F  F  ]  one byte / slot
            └── one compare ──┘
 mask   0  0  1  0  0  0  1  0      check slots 2 & 6
One broadcast-and-compare turns eight probe steps into a single bitmask.

Instead of testing 8 keys one at a time, the runtime broadcasts h2 across a test word and compares it against the whole control word at once, producing a bitmask of candidate slots. On amd64 this is a literal SSE instruction; elsewhere it falls back to bit-trick arithmetic that does the same thing with plain integer ops. A 7-bit fingerprint collides only about 1/128 of the time, so the full key comparison that follows a match is rarely wasted.

Why it saves memory

Packing the table tighter before it grows

A sparse table beside a denser table showing higher load factor

Every open-addressing table grows once it gets too full, because probe sequences degrade as slots run out. The old bucket design triggered a doubling once average load passed 13/16, about 81%. Because the group scan stays cheap even in a nearly full table, Swiss Tables comfortably run denser, often discussed around 87.5% for 8-slot groups. Denser tables mean fewer wasted slots and fewer resize events for the same number of keys.

Datadog put real numbers on this. One production map, map[string]Response with about 3.5M entries at roughly 56 bytes per pair, needed this many buckets under the old design:

buckets=3,500,0008×13/16≈538,462  →  220=1,048,576\text{buckets} = \frac{3{,}500{,}000}{8 \times 13/16} \approx 538{,}462 \;\rightarrow\; 2^{20} = 1{,}048{,}576
Rounding up to a power of two, then caught mid-transition with both old and new arrays live, put this one map near 726 MiB.

After migrating to Go 1.24, Datadog measured about 500 MiB saved on live heap for that single map, close to 1 GiB once GOGC doubling is counted. Netted against an unrelated regression, they saw roughly 600 MiB less memory fleet-wide in high-traffic environments.

The Go-specific problem

Growing a 1 GB map without stalling

A directory routing keys to several small independent Swiss tables

Abseil's Swiss Table grows all at once: it allocates a doubled array and reinserts every entry in one operation. That is fine in C++, but it would wreck Go's tail-latency goals. Growing a multi-gigabyte map in a single insertion would freeze that goroutine while millions of entries are copied.

Go's answer is a new indirection layer. A map is split into multiple independent Swiss Tables under a directory, a form of extendible hashing, and each individual table caps at 1024 entries. A variable number of upper hash bits selects which table a key lives in, and that bit count grows as the map grows.

Directory (upper hash bits)

  • Table A (≤ 1024)
  • Table B (≤ 1024)
  • Table C (growing)

When one table fills, only that table grows, all at once but in isolation, while its neighbours stay untouched. The worst case for any single insertion is now copying one 1024-entry table into two, not copying an entire map. Big maps grow in small, bounded steps.

How this mirrors the old incremental growth

The pre-1.24 design got bounded latency a different way: it kept the old bucket array alive next to the new one and evacuated a few entries on each subsequent write. The Swiss Table rewrite reaches the same guarantee with independent per-table growth instead of a slow evacuation, which is also why Datadog's rarely-written map was caught with two arrays allocated at once.

Preserving the contract

Letting you mutate a map while you range over it

An iterator reading a pinned old table while another grows

Go's spec deliberately allows you to change a map during a range loop. Delete an entry you haven't reached and it won't be produced; update one and you see the new value; add one and it may or may not appear. Abseil's Swiss Tables forbid modification during iteration entirely, so Go could not adopt the design as-is.

The multi-table split rescues this too. An iterator holds a reference to the specific table it is currently walking. If that table grows mid-iteration, the iterator simply keeps reading the old, pre-growth version, preserving the memory-layout order it started with.

range starts ─▶ iterator pins Table B (v1)
                       │
     writer grows Table B ──▶ B (v2)
                       │
 iterator keeps reading B (v1)
   new entries in v2  ✗ never seen
The iterator finishes on the snapshot it began with.

Entries added after that table grows are never observed by the in-progress loop, which is exactly the "may or may not be produced" case the spec permits. So Go gets Swiss Table performance without breaking a guarantee programs have relied on for years, and with no code changes required.

The payoff

A rewrite you never had to notice

Why it matters

Maps sit under so much Go code that a runtime-level win compounds across every program at once, which is why the team rewrote a data structure most people considered solved. Recompiling on Go 1.24 gets denser, cache-friendlier maps with no source changes, and at scale that showed up as hundreds of megabytes and measurable CPU reclaimed. If something misbehaves, GOEXPERIMENT=noswissmap still opts back into the old implementation.

The short version

  • Go 1.24 replaced its bucket-and-overflow map with a **Swiss Table** design, keeping the `make`/index/`delete`/`range` API identical.
  • Groups of **8 slots** share a control word, so one broadcast compare probes all eight at once using a **7-bit fingerprint** of the hash.
  • A higher safe load factor (**~87.5%** vs the old ~81%) packs tables tighter; Datadog saw roughly **600 MiB** less memory fleet-wide.
  • Go added its own layer: a map splits into many independent tables capped at **1024 entries**, so growth stays incremental and low-latency.
  • Pinning an iterator to its table's old version keeps Go's legal **mutation-during-`range`** semantics intact.

Sources

  1. 1.Faster Go maps with Swiss Tables - The Go Programming Language
  2. 2.Golang Maps: How Swiss Tables Replaced the Old Bucket Design
  3. 3.How Go 1.24’s Swiss Tables saved us hundreds of gigabytes | Datadog
  4. 9.abseil / Swiss Tables Design Notes
  5. 10.SwissTables: High Performance HashMaps - by Pratik Pandey