Maps in Go and the Memory That Never Shrinks Back Down
A Go map with 1 million entries held 36,907 KB after delete and clear left 10 or 0 entries; only dropping the map or copying into a fresh one freed it

Go's map grows when it fills and never shrinks. Deleting a key marks its slot empty and decrements the length, and the table that holds the slots stays exactly the size it reached. A map that held a million entries during a nightly job and holds ten now still occupies the memory of the million. For a long running service that keeps such a map in a package variable or a struct, this shows as live heap that does not fall after the load does, and it is not a goroutine leak or an unreleased reference in any sense a profile points at. The map really is that big. This article measures it on Go 1.25 and compares the four ways to get the memory back.
Tested with Go 1.25.6 (Swiss table maps) on Apple M1 Max (10 cores, 32 GB), macOS, a map[int]int with 1000000 entries
It is a sibling of slices that keep huge arrays alive and part of Go memory leaks that are not goroutine leaks. To tell it from a high water mark that the runtime will return, read RSS against the heap. The cornerstone is the Go performance guide.
The experiment
The program fills a map[int]int with a million entries, deletes all but 10, calls clear, drops the map, and then tries the two copying approaches. After every step it forces two collections and prints the live heap above the baseline.
package main
import (
"fmt"
"maps"
"runtime"
)
func live() uint64 {
runtime.GC()
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.HeapAlloc
}
func fill(n int) map[int]int {
m := make(map[int]int)
for i := 0; i < n; i++ {
m[i] = i
}
return m
}
func kb(x uint64) uint64 { return x >> 10 }
func main() {
const n = 1_000_000
base := live()
m := fill(n)
full := live() - base
fmt.Printf("%-44s %8d KB len %d\n", "1,000,000 entries", kb(full), len(m))
for i := 0; i < n-10; i++ {
delete(m, i)
}
fmt.Printf("%-44s %8d KB len %d\n", "after deleting all but 10", kb(live()-base), len(m))
clear(m)
fmt.Printf("%-44s %8d KB len %d\n", "after clear(m)", kb(live()-base), len(m))
m = nil
fmt.Printf("%-44s %8d KB\n", "after m = nil", kb(live()-base))
m = fill(n)
for i := 0; i < n-10; i++ {
delete(m, i)
}
small := maps.Clone(m)
m = nil
fmt.Printf("%-44s %8d KB len %d\n", "10 entries copied with maps.Clone", kb(live()-base), len(small))
n2 := make(map[int]int, len(small))
for k, v := range small {
n2[k] = v
}
small = nil
fmt.Printf("%-44s %8d KB len %d\n", "10 entries copied into a fresh map", kb(live()-base), len(n2))
runtime.KeepAlive(n2)
// the same churn on a map that stays small in count: insert and delete one million different keys.
c := make(map[int]int)
for i := 0; i < n; i++ {
c[i] = i
delete(c, i)
}
fmt.Printf("%-44s %8d KB len %d\n", "insert then delete 1,000,000 distinct keys", kb(live()-base), len(c))
runtime.KeepAlive(c)
}1,000,000 entries 36907 KB len 1000000
after deleting all but 10 36907 KB len 10
after clear(m) 36907 KB len 0
after m = nil 15 KB
10 entries copied with maps.Clone 36926 KB len 10
10 entries copied into a fresh map 16 KB len 10
insert then delete 1,000,000 distinct keys 16 KB len 0Results
| Step | Live heap | Length |
|---|---|---|
| 1,000,000 entries | 36,907 KB | 1,000,000 |
| after deleting all but 10 | 36,907 KB | 10 |
after clear(m) | 36,907 KB | 0 |
after m = nil | 15 KB | n/a |
10 entries copied with maps.Clone | 36,926 KB | 10 |
| 10 entries copied into a fresh map | 16 KB | 10 |
| insert then delete 1,000,000 distinct keys | 16 KB | 0 |
Delete frees nothing. The map held 36,907 KB when full, about 37 bytes per entry for 16 bytes of key and value, and the same after 999,990 deletions. The runtime does not move entries or shrink the table on delete. Other implementations of hash tables do this too, because shrinking needs a rehash and a map that was big once is likely to be big again.
clear empties the map and keeps the table. clear(m), added in Go 1.21, is the right way to reuse a map in a loop, because it is a single pass that sets the slots empty and lets the next fill run without growing. The cost of that behaviour is the same as for delete: the memory stays. Use clear when you will refill the map to a similar size and not when the map is meant to go away.
Dropping the reference is what frees it. After m = nil the table was unreachable and the live heap went back to 15 KB. If the map is a field of a long lived struct, assign a new map to the field. In a package variable, assign a new map to the variable. Anything holding the old map (a goroutine mid-iteration, a closure) keeps the old table alive until it finishes.
maps.Clone keeps the capacity. Cloning the ten survivors gave 36,926 KB, about the size of the original, because the clone is built by copying the table, which preserves its size. It is the right tool for a fast, exact copy and the wrong one for compaction.
Copying into a fresh map compacts. make(map[int]int, len(small)) sizes the new table for ten entries, and copying the entries in used 16 KB. A loop of five lines, or maps.Collect(maps.All(old)) into a presized map, is the compaction step Go leaves to you.
Churn is not the problem. The last row inserts one key, deletes it, and repeats for a million distinct keys. The map never held more than one entry, so it stayed small at 16 KB. It is a map that grew large and then emptied that retains memory, not one with high turnover. A cache whose entry count is steady is fine, and so is a set of in-flight request IDs that holds a few hundred at a time. The case to look for is a burst: an import, a batch, a restart that reloads everything, a one time fan out.
Where this bites
- Per tenant or per user state kept in a map that grows during a busy hour and is trimmed by deleting idle users.
- Deduplication sets (seen IDs, processed offsets) trimmed by age.
- Caches with eviction by
delete, which keep the table at their peak size. - Metrics and label registries where series come and go.
- Batch jobs that run in the service process and load a lookup table, then drop references to entries but not to the map.
In each, the symptom is a stair step in the live heap after collection that matches the peak entry count and never returns, with the map's allocation site at the top of an inuse_space heap profile and a length far below what the memory implies (heap profiles). A log line with len(m) next to the heap size is a cheap way to spot it.
Fixes
- Rebuild when it drops far below its peak. Track the high water mark; when
len(m)falls under a quarter of it, copy the survivors into a presized new map and swap it in under the same lock that guards the map. - Replace instead of delete, for bulk removal. If you are deleting most of the map, build a new one with the entries you keep.
- Use
clearonly for reuse. It is the right tool for a scratch map inside a loop and the wrong one for freeing memory. - Give a map a bound. A cache with a maximum entry count never grows past it. An unbounded map is a design question before it is a memory one.
- Consider a different structure when the data is a dense range of small integers: a slice indexed by ID uses 8 bytes per entry here against about 37 in the map, though the slice has the opposite problem of holding space for the gaps.
What not to do
Do not call runtime.GC() or debug.FreeOSMemory() to fix this. The table is reachable, so the collector will not free it, and the lab shows the live heap staying at 36,907 KB through two forced collections. Do not rely on the pointer values in a map to shrink anything either: deleting a key with a pointer value drops the reference to the target, which can free a large object, but the slot still occupies its share of the table.
Checklist
- Maps grow and do not shrink on
deleteor onclear. - Release the memory by dropping the map or copying survivors into a presized one.
maps.Clonecopies the capacity, so it is not a compaction.- The retained size follows the peak entry count, not the current one.
- Rebuild a map after a burst, under the lock that guards it.
The built-ins are described in the language specification and the helpers in the maps package documentation.


