| name | go-perf-maps |
| description | Guides high-performance Go maps — the Swiss Tables map (default since Go 1.24; GOEXPERIMENT=noswissmap to A/B), preallocating make(map[K]V, n), key-type hashing cost (int beats large string/array/struct keys), maps never shrinking after delete (recreate to reclaim), clear() vs realloc, map[K]struct{} sets, and when a slice beats a map for small N. Fires on "preallocate this map", "map memory not freed", "faster map keys", "map is slow". Routes correctness to go-slices-and-maps, concurrent maps to go-perf-contention-and-sharding. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
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).
m := make(map[string]int)
for _, k := range keys {
m[k] = score(k)
}
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
int key hashes and compares in a couple of instructions;
a long string or a big struct does not.
- A large array or struct key is expensive. A
[256]byte array 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]V does not compile; the
idioms are map[string]V with string(b) (the m[string(b)] lookup is copy-elided — depth in
go-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.
for k := range huge {
delete(huge, k)
}
clear(huge)
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.
seen := map[string]bool{}
seen[id] = true
if seen[id] { }
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).
kind := map[string]int{"get": 0, "put": 1, "del": 2}
n := kind[op]
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.Map vs a sharded map[K]V+Mutex under 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 — the m[string(b)] copy-elision and []byte↔string cost.
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
n is known — make(map[K]V, n) skips grow-and-reinsert churn (src/internal/runtime/maps/map.go NewMap).
- Don't assume
delete or clear frees memory — maps never shrink; reassign a fresh map to reclaim (golang/go#20135; map.go Clear).
- Don't re-
make a map you refill every cycle — clear(m) empties it and keeps capacity (builtin clear).
- Don't use
map[K]bool for a set where map[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
*T values 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
switch or 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:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml