Go Map Performance
Correctness — nil-map writes panic, iteration order is randomized, maps.Clone/maps.Keys
aliasing, prealloc-as-a-correctness-hint — belongs to go-slices-and-maps; read it first.
This skill owns only the performance layer: what the Swiss Tables implementation changed,
when preallocation pays, what a key type costs to hash and compare, why a map's memory never comes
back after delete, and when a map is the wrong structure. Concurrent access (sync.Map,
sharding) is go-perf-contention-and-sharding; allocator/no-scan-span depth is
go-perf-allocator-internals.
All Go below builds clean under gofmt, go vet, and go test on Go 1.26.
1. The Swiss Tables Map (default since Go 1.24)
"Go 1.24 includes a completely new implementation of the built-in map type, based on the Swiss
Table design" (Swiss Tables blog). It is the default; you can A/B
against the old map by building with GOEXPERIMENT=noswissmap, which "may be disabled by setting
GOEXPERIMENT=noswissmap … at build time" (Go 1.24).
The measured win. "In microbenchmarks, map operations are up to 60% faster than in Go 1.23 … some edge cases do regress … Overall, in full application benchmarks, we found a geometric mean CPU time improvement of around 1.5%" (Swiss Tables blog); the release notes fold it into runtime changes that together "decreased CPU overheads by 2–3% on average" (Go 1.24). A real but modest whole-program win — do not expect 60%.
Why it's faster (one paragraph). Storage is "an array of groups," each group being
abi.MapGroupSlots (8) slots "plus a control word" — a 64-bit word whose 8 bytes each hold whether a
slot is empty/deleted/used and the "lower 7 bits of the hash (H2)" for used slots
(src/internal/runtime/maps/map.go header). A lookup compares all 8 7-bit hashes against the input
hash "in parallel," doing "8 steps of probing in a single operation" — the idea SIMD hardware does in
one instruction (Swiss Tables blog). The old map walked 8 slots one
at a time and chained overflow buckets; the control-word trick plus a higher load factor
(maxAvgGroupLoad = 7 of 8 slots, ~87.5%) is both the speedup and the lower memory footprint
(src/internal/runtime/maps/group.go). You rarely touch these internals — the levers below are what
you pull; hashing-internals depth is out of scope.
2. Preallocate make(map[K]V, n) When You Know the Size
A map that grows from empty rehashes and reorders its backing storage repeatedly. Passing a size hint
sizes the directory once: NewMap "Set[s] initial capacity to hold hint entries without growing in
the average case," computing targetCapacity := (hint * 8) / maxAvgGroupLoad
(src/internal/runtime/maps/map.go NewMap). The Go-specific design caps a single table at maxTableCapacity = 1024
entries and splits beyond that via extendible hashing, so a large map is many independent tables — but
the hint still pre-sizes the directory to skip the grow-and-reinsert churn (src/internal/runtime/maps/table.go;
Swiss Tables blog).
// WRONG — grows and reinserts repeatedly as it fills.
m := make(map[string]int)
for _, k := range keys { // len(keys) is known
m[k] = score(k)
}
// RIGHT — size the directory once up front.
m := make(map[string]int, len(keys))
for _, k := range keys {
m[k] = score(k)
}
A hint of 8 or fewer does nothing — "a small map can fill all 8 slots, so no need to increase target
capacity" (src/internal/runtime/maps/map.go NewMap). Prealloc only matters when n is large enough to force grows.
3. The Key Type Is Not Free — Hash It and Compare It on Every Op
Every lookup, insert, and delete hashes the key and compares it against candidate slots. The runtime
ships specialized fast paths for the common key widths — runtime_fast32.go, runtime_fast64.go,
runtime_faststr.go — so int/int32/int64/string keys are cheaper than a generic key
(src/internal/runtime/maps). Even within strings the cost shows: the fast-string path adds a
"quick equality test" only for keys "longer than 64 [bytes]" because "string hashing and equality
might be expensive" for long strings (src/internal/runtime/maps/runtime_faststr.go).
Consequences for key choice:
- Prefer small, fixed-width keys. An
intkey hashes and compares in a couple of instructions; a longstringor a big struct does not. - A large array or struct key is expensive. A
[256]bytearray key is comparable (so it is a legal key) but every op hashes and==-compares all 256 bytes. If a short stable identifier exists (an ID, a small[16]byte), key on that instead. - A slice can never be a key — slices are not comparable, so
map[[]byte]Vdoes not compile; the idioms aremap[string]Vwithstring(b)(them[string(b)]lookup is copy-elided — depth ingo-perf-strings-bytes-zerocopy) or a fixed-size array key.
Key/element correctness (comparability rules) is go-slices-and-maps; here the point is purely
cost: small keys are faster.
4. Maps Never Shrink — Recreate to Reclaim Memory
delete removes an entry but never returns the backing storage. A map that once held 1M entries
keeps that directory and its tables after you delete down to 10. Deletion either marks a slot empty or,
in a full group, leaves "a tombstone," and tombstones "are only completely cleared during grow"
(src/internal/runtime/maps/map.go header) — there is no shrink path. Even clear(m) keeps the storage: its
implementation empties every table but leaves the directory allocated, marked literally
// TODO: shrink directory? (src/internal/runtime/maps/map.go Clear). This is the long-standing, still-open
golang/go#20135 "runtime: shrink map as elements are deleted",
whose report is exactly the multi-million-entry map that OOMs after deletes.
// WRONG — assumes deleting entries frees memory. It does not; the
// directory and tables that held 1M entries stay resident.
for k := range huge {
delete(huge, k)
}
// RIGHT (reuse, keep capacity) — clear() for the same map reused each
// cycle: empties entries, KEEPS storage. Best when it refills to a
// similar size, avoiding re-grow.
clear(huge)
// RIGHT (reclaim) — to actually give memory back, drop the reference
// and build a new (right-sized) map. The old one is GC'd.
huge = make(map[string]int, expectedSize)
clear(m) (Go 1.21) "deletes all entries, resulting in an empty map" and is a no-op on a nil map
(builtin clear); it is the right tool to empty-and-reuse a map
on a hot loop (no realloc, capacity retained). It is the wrong tool when the goal is to release
memory — for that, reassign a fresh map and let the old one be collected.
5. Sets: map[K]struct{} Stores Zero Value Bytes
For a set you only need key presence, not a value. struct{} is a zero-width type (an empty struct
"has size zero"; Go spec, Size and alignment guarantees),
so map[K]struct{} stores no per-entry value storage, whereas map[K]bool spends a byte (plus
slot padding) per entry on a value you never read.
// WRONG — a byte of value per entry, and an ambiguous false-vs-absent.
seen := map[string]bool{}
seen[id] = true
if seen[id] { /* ... */ }
// RIGHT — zero value bytes; membership via the comma-ok form.
seen := map[string]struct{}{}
seen[id] = struct{}{}
if _, ok := seen[id]; ok { /* ... */ }
The membership test is the comma-ok _, ok := form. Reach for map[K]bool only when you genuinely
need three states (present-true / present-false / absent).
6. Pointer-Free Key/Element Types Cut GC Scan Work
The GC must scan map storage for pointers — the runtime tracks and clears pointers in key/elem slots
on delete (typ.Key.Pointers() / indirect-key handling in src/internal/runtime/maps/map.go
Delete). A map whose key and element types contain no pointers (e.g. map[int64]uint32) lands
in storage the collector need not walk; map[string]*T makes every live entry GC work (both the
string header and the *T are pointers). For a large, long-lived map, prefer pointer-free key/elem
types — store an index or a value, not a *T. Allocator/no-scan-span depth is
go-perf-allocator-internals; the map-shaped rule is just: fewer pointers in K and V → less GC
scan.
7. When a Map Is the Wrong Structure (small N)
A map costs a hash plus a probe on every access and has poor cache locality (entries are scattered across groups). For small N, a linear scan of a slice — branch-predictable, cache-friendly, no hashing — beats it; "it's very easy for lookup tables to be far away in memory" so a contiguous slice often wins (go-perfbook).
// WRONG — a map for a handful of fixed keys, rebuilt or consulted hot.
kind := map[string]int{"get": 0, "put": 1, "del": 2}
n := kind[op]
// RIGHT (tiny, fixed set) — a switch the compiler lowers to a jump,
// no hashing, no allocation.
var n int
switch op {
case "get":
n = 0
case "put":
n = 1
default:
n = 2
}
Guidelines: a handful of compile-time-known keys → a switch; a small, mostly-static set → a sorted
slice with slices.BinarySearch (O(log n), contiguous memory); a genuinely large or dynamic keyset →
a map. As always, the cutover is workload-specific — measure it (go-perf-benchmarking-statistics),
don't guess. Slice growth/reuse mechanics are go-perf-slices.
8. Routing to Related Skills
go-slices-and-maps(marketplace, correctness) — nil-map panic, randomized iteration order,maps.Clone/Keys/Values, key comparability rules, prealloc as a correctness hint.go-perf-contention-and-sharding— concurrent maps:sync.Mapvs a shardedmap[K]V+Mutexunder write-heavy load.go-perf-allocator-internals— size classes, no-scan vs scan spans, the true cost of a map's table allocations.go-perf-strings-bytes-zerocopy— them[string(b)]copy-elision and[]byte↔stringcost.go-perf-slices— slice growth/prealloc/reuse for the sorted-slice and scan alternatives.go-perf-methodology/go-perf-benchmarking-statistics— confirm any map change with a benchstat A/B; the map-vs-slice cutover is a measurement, not folklore.
9. Don't
- Don't leave a large map unsized when
nis known —make(map[K]V, n)skips grow-and-reinsert churn (src/internal/runtime/maps/map.goNewMap). - Don't assume
deleteorclearfrees memory — maps never shrink; reassign a fresh map to reclaim (golang/go#20135;map.goClear). - Don't re-
makea map you refill every cycle —clear(m)empties it and keeps capacity (builtinclear). - Don't use
map[K]boolfor a set wheremap[K]struct{}stores zero value bytes (Go spec). - Don't key a hot map on a long string or big array when a small fixed-width id hashes far cheaper (
src/internal/runtime/maps/runtime_faststr.go). - Don't store
*Tvalues in a large long-lived map if a value or index avoids the GC scan (depth:go-perf-allocator-internals). - Don't reach for a map for a handful of keys — a
switchor sorted-slice scan wins for small N (go-perfbook). - Don't quote the 60% microbenchmark figure as your app's win — the whole-program number is ~1.5% geomean (Swiss Tables blog).
10. Reference Files
Wrong/right map performance anti-patterns with citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml