A daily aggregation job deletes every entry from its Go map, yet the heap never drops — why?
answer
- maps grow, maps never shrink
- delete frees the contents, not the table
- clear keeps capacity on purpose
- you pay the high-water mark
- replace the map, do not empty it
basics
~20 sGo maps never shrink. delete and clear release the stored keys and values but keep every slot the map ever allocated, so an emptied map keeps its peak footprint. Assign a freshly made map to reclaim it.
solid answer
~50 sA Go map grows on demand and never gives storage back. `delete` clears the slot's key and value, so whatever they referenced can be collected, but the table itself stays at its high-water mark. `clear(m)` behaves the same way: it empties the map and deliberately keeps the capacity, so a refill costs no regrowth. After a spike day pushes a counter map keyed by a `(tenant, region)` struct into the millions of entries, an emptied map is still a large live object that the collector must keep, because your variable still refers to it. The fix is to stop reusing the map: assign a fresh `make(map[key]int, expected)` at the start of each run so the old one becomes garbage, or scope the map to a single input file and flush a much smaller rollup out of it.
code
go · 11 linestype key struct {
tenant, region string
}
counts := make(map[key]int, 1024)
// ... a spike day pushes counts to millions of entries ...
clear(counts) // len is now 0, but every slot the map grew is still allocated
// To actually reclaim it, drop the map itself:
counts = make(map[key]int, 1024) // the old map is unreachable and can be collectedgo deeper
Remember the rule: a Go map only ever grows. Deleting the entries or calling clear empties it but does not hand the memory back.
Explain what delete really does — clears the slot's key and value so their referents can be collected, while the table storage stays — and say when keeping capacity via clear is exactly the behaviour you want.
Diagnose it end to end: demonstrate the map sits at its peak footprint while len is near zero, fix it by scoping the map to one run or one file so the old one becomes garbage, and confirm with a benchmark instead of by feel.
Own the steady-state footprint of long-lived state: decide whether a key like (tenant, region) has bounded cardinality, whether aggregates are rebuilt or kept, and who reviews that growth budget before it becomes an incident.
## The behaviour A Go map allocates storage as it fills and **never releases it**. There is no shrink threshold, no compaction pass, no automatic downsizing when the entry count falls. The map's memory footprint tracks its **high-water mark**, not its current `len`. This is a deliberate design choice, not an oversight. Shrinking would mean rehashing every surviving entry into smaller storage — an unpredictable pause — and would punish the extremely common pattern of a map that is emptied and refilled repeatedly. ## What delete and clear actually do `delete(m, k)` removes the entry and clears the memory of the slot's key and value. That part matters: if the key or value contained pointers — strings, slices, structs with references — those references are dropped, so the objects they pointed at become collectable. What is *not* released is the table storage itself: the slots and their bookkeeping stay allocated, marked available for reuse. `clear(m)` empties the map in one operation, with the same outcome for capacity: entries gone, capacity kept. That is the right behaviour when you refill the same map every request or every loop iteration — no reallocation, no rehashing. It is the wrong tool when the goal is to give the memory back. So an emptied map is a strange object: `len(m) == 0`, and it is still hundreds of megabytes. ## The batch-job shape of this bug A job that aggregates daily log files into a map keyed by a `(tenant, region)` struct is the classic case. On an ordinary day the key cardinality is modest. Then one day a misbehaving producer emits a region field derived from something unbounded — a request id, a hostname with a random suffix — and the map explodes to millions of distinct keys. The job finishes, empties the map, and moves to the next file. Memory does not come back, and every subsequent run in that process carries the peak. If the process is long-lived, the spike is permanent for its lifetime; the next spike ratchets it higher. ## Confirming it is the map You want evidence, not a hunch: - **Check the shape of the claim first.** `len(m)` near zero while the process's in-use heap stays flat across an explicit collection is already suggestive. For a batch job, `runtime.GC()` followed by `runtime.ReadMemStats` and a look at `MemStats.HeapAlloc` before and after emptying the map is a two-line experiment. - **Reproduce it in a benchmark.** Fill one map to N entries, empty it, and repeat across a growing N, run with `-benchmem`. The bytes-per-operation figure tracks the peak N rather than the final size, which is the behaviour stated back to you as a number. - **Check key cardinality, not just size.** The interesting question is usually *why* the map got that big. Logging the distinct key count per run turns a memory mystery into a data-quality bug. ## The fixes, in order of preference **1. Give the map a shorter life.** Build a new map per run, per file, or per window, and let the previous one become garbage: ```go counts = make(map[key]int, expected) ``` Once nothing references the old map, the collector reclaims it whole. This is nearly always the right answer for a batch job, and it costs one allocation per run. **2. Bound the cardinality.** If the key can be unbounded, make it bounded on purpose: validate the region field, or fold rare pairs into an explicit `other` key once the distinct count crosses a threshold. This turns an unbounded growth risk into a known, capped one, and it usually improves the output too — a report with two million one-row groups was not useful anyway. **3. Rebuild a long-lived map periodically.** If the map genuinely must stay live as a cache, copy the survivors into a right-sized new map when the live count falls far below the peak, and swap it in. You pay one copy and get the footprint back. **4. Shard by time.** Keep several maps, one per window, and drop whole windows. Deleting a map is free in a way that deleting its entries is not. ## What not to do - Calling `runtime.GC()` does not shrink a live map. The map is reachable; the collector is doing exactly its job. - Looping `delete` over every key is strictly worse than `clear`, and neither returns capacity. - Setting the map variable to `nil` *does* work — it makes the map unreachable — but only if no other reference remains, and you then have to remake it before the next write, since writing to a nil map panics. ## The one-sentence version **Maps grow and never shrink; to get the memory back you must throw the map away, not empty it.** Say that, then say how you would verify it, and the answer is complete.
- How would you confirm the map is what is holding the memory?Show that len(m) is near zero while in-use heap stays flat across a forced collection — runtime.GC followed by runtime.ReadMemStats and MemStats.HeapAlloc is enough for a batch job. Then reproduce it in a benchmark that fills and empties one map across a growing key count: under -benchmem the bytes per operation track the peak, not the final size.
- Isn't clear(m) supposed to free the memory?No. clear deletes every entry and keeps the capacity, which is exactly right for a map you refill each request or iteration — no reallocation, no rehashing. It is the wrong tool when you want the footprint back; for that the map object itself has to become garbage.
- What if the map must stay live as a long-running cache?Rebuild it when the live count falls far below the peak: copy the survivors into a right-sized new map and swap it in, paying one copy for a smaller steady footprint. Alternatively shard by time window so an entire shard can be dropped rather than emptied entry by entry.
- Why did the Go team choose never to shrink maps?Shrinking means rehashing every surviving entry into smaller storage, an unpredictable pause at an arbitrary moment, and it would penalise the very common empty-and-refill pattern. Keeping capacity makes cost predictable and leaves the decision to the programmer, who knows whether the map will refill.
Emptying a warehouse does not end the lease: the floor space stays yours and stays on the bill until you hand the building back.
saying these in an interview costs you the question
- Expects delete to hand memory back to the heap
- Thinks clear(m) shrinks the map's capacity
- Blames the garbage collector for not collecting a map that is still live
- Suggests calling runtime.GC to shrink the map
- Assumes a map halves itself once it is mostly empty