RSSAmplifier

Pickles · Jul 1, 2026

What Actually Changed Inside Go's map (Swiss Tables)

0
Sign in to vote or save

Pickles · Pickles

You write map[string]int constantly — caches, indexes, counting things, deduping, grouping, looking up by ID. It is one of the most-used structures in any Go program. And in Go 1.24 it got faster, with you changing nothing: the runtime quietly swapped the guts of the built-in map for a design called a Swiss Table. Same map, same API, denser and quicker underneath.

It’s a genuinely nice piece of engineering, and worth understanding for two reasons. One: maps are everywhere in your code, so knowing why the built-in one behaves the way it does is just useful. Two: the most interesting part isn’t the speedup — it’s why Go couldn’t simply copy the famous version of this data structure, which is a small lesson in the difference between a clever idea and a clever idea that has to live inside a real language.

The old way: buckets and overflow chains

Before 1.24, Go’s map was a fairly classic chained hash table. Keys were distributed into buckets; when too many keys landed in the same bucket, the bucket grew an overflow bucket, chained on after it. Looking up a key meant: hash it, find its bucket, then walk that bucket (and any overflow buckets) comparing keys until you found a match or ran out.

That works, but it has a cost that bites exactly where maps are used most. Every candidate in the chain may need a full key comparison, and a full comparison is cheap for a small int and not at all cheap for a string — you’re potentially comparing byte by byte, for every key in the chain, on every lookup. When your keys are strings (and in real code, they very often are), those comparisons add up.

The Swiss idea: check eight at once

The Swiss Table — popularized by Google and its open-source Abseil implementation — attacks exactly that. It’s an open-addressing table (entries live directly in one array; collisions are resolved by probing nearby slots rather than chaining off to separate buckets), and its trick is to make lookups batched: instead of examining slots one at a time, it filters several candidates in a single step.

The mechanism is a small block of control bytes. Go’s runtime arranges the table into groups of 8 slots, and alongside each group it keeps an 8-byte control word — one byte per slot. Each control byte is either a sentinel (this slot is empty, or it’s been deleted) or, for an occupied slot, a tiny 7-bit fingerprint of that slot’s key hash.

one group = 8 slots + an 8-byte control word

control:  [ 5c | a1 | empty | 3f | 5c | del | 91 | c2 ]   ← scan all 8 fingerprints at once
slots:    [ k,v| k,v|  ---  | k,v| k,v| --- | k,v| k,v]
            ↑ only the slots whose fingerprint matches get a full key comparison

Because those eight fingerprints sit together in one machine word, the table can scan all eight at once and instantly narrow “which of these slots could possibly be my key” down to the few whose fingerprint matches — before touching the actual keys. The expensive full key comparison happens only for those few candidates. A 7-bit fingerprint can collide, so the final key comparison is still done to be sure; it just happens far less often. On CPUs with SIMD this batching goes even faster, but it pays off even with plain bit operations, because it turns “compare keys one by one” into “reject seven of eight slots with a single cheap test.”

H1 and H2: pick a group, then filter inside it

Where does the fingerprint come from? The runtime splits a key’s hash into two parts. The top 57 bits — call it H1 — choose where to start: which group (and, as we’ll see, which table). The low 7 bits — H2 — are the fingerprint stored in the control byte.

So a lookup reads, end to end, like this: hash the key; use H1 to jump to the right group; scan that group’s eight control bytes for any equal to H2; for each fingerprint match, do the real key comparison; if there’s no match and the group has an empty slot, the key isn’t here. The whole point is that H2 does the cheap filtering and the costly key comparison is the rare exception, not the rule.

Why Go couldn’t just copy Abseil

Here’s the part worth slowing down for. If you only read the popular explainers, the obvious conclusion is “great, port Abseil’s Swiss Table into the Go runtime and ship it.” In practice Go’s built-in map has language and runtime requirements that the textbook version doesn’t, and those forced real changes.

Problem one: growing without a latency spike. A typical hash table grows by allocating an array twice the size and rehashing everything into it at once. For a server with tight latency requirements, that’s a landmine — one unlucky insert suddenly becomes a huge copy, and your p99 jumps. Go has always cared about this and grown its maps incrementally. To keep that property, Go does not build one giant Swiss Table for the whole map. Instead a map is a directory of one or more independent tables, each holding at most 1024 entries, with the top hash bits selecting which table a key belongs to — a form of extendible hashing. When a single table fills up, only that table grows; the worst an insert can pay is the cost of growing one 1024-entry table, not rehashing the entire map.

Problem two: changing a map while you range over it. Go’s spec is unusually lenient here: you’re allowed to modify a map during a range loop, with specific rules — an element deleted before the iterator reaches it won’t be yielded, an updated value must be seen as the new value, a newly added element may or may not appear. That’s a real guarantee the runtime has to honor, and it’s genuinely hard: if the table grows and relocates entries mid-iteration, a naive walk through memory would start seeing a different structure than the one it began on. Abseil makes no such promise; Go’s map has to. So the implementation isn’t a copy of a blog post — it’s a careful adaptation that keeps Go’s existing semantics intact. Porting a data structure and honoring a language’s contract turn out to be different difficulty levels.

A subtle bit: why deletion isn’t just “mark it empty”

There’s a wrinkle that shows how careful this design has to be. An open-addressing table can never be allowed to fill completely, because a lookup needs to be guaranteed to hit an empty slot to know when to stop probing. If the table were 100% full, a search for a missing key could loop forever — so the table keeps headroom by design.

That headroom is also why deletion is trickier than it looks. You can’t always just mark a deleted slot empty, because an empty marker tells future lookups “stop probing here” — and a key that originally probed past this slot would suddenly become unfindable. So sometimes a delete really can mark the slot empty (when the group still has an empty slot, meaning no probe chain ran past it), and sometimes it has to leave a special deleted marker that means “this slot is free to reuse, but don’t treat it as the end of a search.” It’s a small detail, but it’s exactly the sort of thing that separates a working hash-table implementation from a diagram of one.

What it means for your code

For 99% of code, the honest answer is: nothing changes that you can see. Your map[string]User is still map[string]User. Every property you relied on still holds — range order is still unspecified, writing to a nil map still panics, and concurrent access from multiple goroutines without synchronization is still a data race. Swiss Tables changed the engine, not the controls.

What changes is speed and memory. Faster in-group filtering, fewer key comparisons, and denser packing mean reads and inserts can be quicker and the map can use memory more efficiently — but none of it rewrites the fundamentals of good Go map usage:

  • If a map is shared across goroutines, you still need a mutex (or sync.Map, or sharding). The new map is not concurrent-safe.
  • If your keys are heavy (long strings, big structs), the cost of hashing and comparing them doesn’t vanish — Swiss Tables reduce how often you compare, not how expensive each comparison is.
  • If you churn out big temporary maps every millisecond, you’re still pressuring the garbage collector, Swiss Table or not.
  • Preallocating a sensible capacity with make(map[K]V, n) when you know the rough size still helps, by avoiding intermediate growth.

Measure it before you believe it

The grown-up part. The Go team’s own framing is deliberately modest: across a set of representative benchmarks they measured roughly a 2–3% average reduction in runtime overhead, and they’re explicit that the real result depends entirely on your workload. Microbenchmarks can show a big effect; a real service is dominated by allocation, I/O, serialization, and the scheduler, so the win lands wherever your map is actually a hot spot — and nowhere in particular if it isn’t.

The good news is you don’t have to guess, because Go 1.24 lets you turn the old map back on at build time and run a clean A/B test on your own code:

# normal Go 1.24 (Swiss Tables)
go test -bench=. -benchmem ./... > new.txt

# the same code, old map implementation
GOEXPERIMENT=noswissmap go test -bench=. -benchmem ./... > old.txt

benchstat old.txt new.txt

Compare not just nanoseconds but allocs/op and B/op, on benchmarks that mirror how your code actually uses maps — bulk lookups by string key, inserts into prepared maps, grouping a big slice, counting frequencies. A representative one might be a hot lookup loop over string keys:

func BenchmarkLookup(b *testing.B) {
    m := make(map[string]int, len(keys))
    for i, k := range keys {
        m[k] = i
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = m[keys[i%len(keys)]]
    }
}

Run that under both toolchains and benchstat tells you whether your map is somewhere Swiss Tables actually help — which beats trusting someone else’s microbenchmark from the internet.

You’ve been using Swiss Tables already

If this feels like it came out of nowhere for Go, it didn’t — Go is, if anything, late to it. The control-bytes-and-groups layout started in Google’s C++ Abseil library (flat_hash_map), and Rust’s standard HashMap has been a Swiss Table since 2019, when the standard library adopted the hashbrown crate. So the same idea that now backs your map[string]int is also what backs a Rust HashMap and an enormous amount of production C++. Go’s contribution to that lineage isn’t the structure — it’s the adaptation: making it grow incrementally and survive modification during iteration, so it could slot in under an existing language’s guarantees without changing a line of anyone’s code. The strong ideas in systems programming tend to converge, and “a fast open-addressing hash map with SIMD-friendly fingerprints” is one of them — Go just arrived at the party with its own house rules.

The nice kind of upgrade

What I like about the Swiss Table story isn’t the percentage — it’s the shape of the change. Go didn’t make you rewrite an API, learn a new collection, or adopt a new syntax. It improved one of the most fundamental structures in the language, in the runtime, in a way that hands the ordinary developer a free win the moment they bump their toolchain. And it did it without chasing the prettiest theoretical design: the implementation bent the Swiss Table to fit Go’s actual requirements — predictable growth, correct iteration under mutation, the existing map contract — rather than the other way around.

So the next time you type map[string]int without a second thought, know that the thing underneath got quietly smarter, denser, and more interesting in 1.24 — and that the reason it took real engineering wasn’t the clever data structure, but the unglamorous work of making a clever data structure keep all the promises your code already depends on. That’s the kind of upgrade a mature language gives you: better, for free, and you never had to know — but it’s more fun once you do.

Read the original on pickles.news

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.