# Go Perf Maps

> 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.

- Skill: `ctoth/go-perf-maps` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-maps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-maps/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-maps

---


# 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](https://go.dev/blog/swisstable)). 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](https://go.dev/doc/go1.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](https://go.dev/blog/swisstable)); the release
notes fold it into runtime changes that together "decreased CPU overheads by 2–3% on average"
([Go 1.24](https://go.dev/doc/go1.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](https://go.dev/blog/swisstable)). 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](https://go.dev/blog/swisstable)).

```go
// 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 `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"](https://github.com/golang/go/issues/20135),
whose report is exactly the multi-million-entry map that OOMs after deletes.

```go
// 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`](https://pkg.go.dev/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](https://go.dev/ref/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.

```go
// 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](https://github.com/dgryski/go-perfbook)).

```go
// 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.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](https://github.com/golang/go/issues/20135); `map.go` `Clear`).
- **Don't re-`make` a map you refill every cycle** — `clear(m)` empties it and keeps capacity ([builtin `clear`](https://pkg.go.dev/builtin#clear)).
- **Don't use `map[K]bool` for a set** where `map[K]struct{}` stores zero value bytes ([Go spec](https://go.dev/ref/spec#Size_and_alignment_guarantees)).
- **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](https://github.com/dgryski/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](https://go.dev/blog/swisstable)).

---

## 10. Reference Files

Wrong/right map performance anti-patterns with citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

