Why does a CPython dict still hold a 2.4 GB table after a document-conversion queue deletes every finished job?
answer
- Deleting is not the same as reclaiming
- The hole stays; the cursor moves forward
- Only one operation ever re-sizes it
- Burst then quiet is the failure shape
- Rebuild or clear to reclaim
basics
~20 sDeleting keys never shrinks a CPython dict; a delete only tombstones the slot. Tables are resized by insertions that exhaust the usable slots, so a drained dict keeps its peak-sized table until you rebuild or clear it.
solid answer
~40 s`del d[key]` writes a deleted marker into the index slot and clears the entry in the dense array. The values are released, but neither array is reallocated and the append cursor never rewinds — a dict that peaked at ten million jobs keeps a ten-million-sized table with a length of zero. The table is only ever re-sized *on insertion*, when the usable slots run out; the replacement is sized from the live entry count, so that is also the moment it can shrink. A queue that burst, drained and went quiet never reaches that moment. Confirm it with `sys.getsizeof(d)` against `len(d)` — a huge size with a tiny length is the signature. The fixes are to rebuild (`d = dict(d)`), call `d.clear()` when draining, or bound the mapping so the peak never happens.
code
python · 10 linesimport sys
d = {i: i for i in range(100_000)}
peak = sys.getsizeof(d)
for k in list(d):
del d[k]
print(len(d), peak, sys.getsizeof(d))
d = dict(d) # rebuild from the live entries
print(sys.getsizeof(d))go deeper
Take away one rule: removing keys from a dictionary does not give the memory back. If you built a huge mapping and only need a few entries, build a new dictionary from what you kept rather than deleting the rest.
Explain the mechanics: a delete tombstones the index slot and blanks the entry, the append cursor never rewinds, and only an insertion that exhausts usable slots rebuilds the table. Know that the rebuild is sized from live entries, which is when shrinking happens.
Diagnose it in a live service. Compare reported size against length, distinguish a retained table from allocator fragmentation, and pick the right repair — rebuild after a peak, clear on drain, or bound the mapping so the peak cannot occur.
Treat an unbounded per-item registry as a capacity decision, not a memory bug. Set the expectation that in-flight state has a documented ceiling and a shedding strategy, and that a burst-shaped workload is sized for its peak, not its average.
### The signature A conversion service keeps per-job state in a long-lived dict keyed by job id. A backlog pushes concurrency to millions of in-flight jobs, the backlog drains, every finished job is deleted — and the process still sits on a 2.4 GB working set with a dict whose `len()` is in the hundreds. Nothing is leaking in the reference-counting sense: the job objects really were freed. What did not go away is the dict's own table. ### What deletion actually does A CPython dict is a sparse index array over a dense, append-only entries array. `del d[key]` does three things: it clears the entry's key and value slots in the entries array, it writes a *deleted* marker into the index slot, and it decrements the live count. It does **not** move later entries down, it does **not** rewind the append cursor, and it does **not** reallocate either array. The hole stays. The deleted marker is necessary rather than sloppy: an open-addressed probe sequence that hit a truly empty slot would stop early and fail to find keys inserted after the deleted one. Marking preserves probe chains. The practical consequence is stark: ```python import sys d = {i: i for i in range(100_000)} peak = sys.getsizeof(d) for k in list(d): del d[k] print(len(d), peak, sys.getsizeof(d)) # 0 5242960 5242960 ``` Zero entries, five megabytes of table. ### Why an insertion does not necessarily fix it The intuitive repair — "put something back in and it will right-size itself" — usually does nothing: ```python d["next"] = 1 print(sys.getsizeof(d)) # still 5242960 ``` A resize is triggered only when an insertion finds no usable slot left. Filling 100,000 entries into a table of 262,144 slots leaves tens of thousands of usable slots unconsumed, and deletion does not hand any back — the append cursor only ever moves forward. So one insert simply takes the next unused slot. When the usable slots finally do run out, the rebuild is sized from the number of **live** entries, roughly three times that count rounded up to a power of two. That is the moment a dict can get smaller, and it genuinely does: ```python for i in range(200_000): d[i] = i del d[i] print(len(d), sys.getsizeof(d)) # 1 224 ``` This is the important nuance for diagnosis. A dict under *steady churn* — add and delete at similar rates — repeatedly exhausts its usable slots, compacts on the resulting resize, and stays roughly proportional to its live size. The failure mode is the opposite shape: a **burst then quiet**. Peak, drain, and no further insert pressure means no resize, and the peak-sized table is retained for the life of the dict. ### Confirming it Compare the reported size against the length. `sys.getsizeof(d)` reports the dict object plus the table it owns (not the keys and values themselves), so a dict with `len(d) == 300` reporting tens of megabytes is conclusive on its own. To find *which* mapping, walk your long-lived registries and print `len` alongside `sys.getsizeof`, or take `tracemalloc` snapshots either side of a drain and diff them: a retained table shows as an allocation that never goes away rather than as growth. One honest caveat before blaming the dict for the whole 2.4 GB: freeing objects returns memory to the interpreter's allocator, which only returns an arena to the operating system once that arena is entirely empty. Fragmentation alone can keep RSS high after a burst even with no retained table. Measure the container before you conclude. ### The fixes **Rebuild.** `d = dict(d)` builds a fresh, right-sized table from the live entries and drops the old one. Cheap, obvious in review, and the standard repair for a mapping that has passed its peak. **Clear when draining.** `d.clear()` releases the table outright and resets the dict to its empty state — a 50,000-entry dict goes from 2.6 MB to 64 bytes. Use it when the whole generation of state is done, not per key. **Bound the mapping.** The design fix. If the dict tracks in-flight work, its size is a capacity decision: cap concurrency, evict on completion into a bounded structure, or move the state out of process. A per-job registry that can reach millions of entries is an unbounded queue with extra steps. **Do not reach for `gc.collect()`.** The table is reachable and refcounted; there is no cycle to break and collection will not shrink it.
- Why does a dict under constant add-and-delete churn stay small instead of growing forever?Because churn keeps consuming usable slots. Deleted entries are never reused in place, so each insert takes a fresh slot until none are left; that insert triggers a rebuild sized from the live entry count, which compacts every hole away. Steady churn therefore self-corrects. The pathological shape is a burst followed by quiet, where no insert ever forces the rebuild.
- How would you confirm the retained table is the mapping rather than the objects it held?Compare `sys.getsizeof(d)` with `len(d)`. That call reports the dict object plus the table it owns, and not the keys and values, so a length of a few hundred against tens of megabytes points squarely at the table. For the process-level picture, diff `tracemalloc` snapshots taken either side of a drain: a retained table appears as an allocation that never disappears.
- Why does a deletion leave a marker instead of simply emptying the slot?Because lookups probe a sequence of slots and stop at the first genuinely empty one. If a delete blanked the slot, any key that had been placed further along the same probe chain would become unreachable even though it is still in the table. The deleted marker keeps the chain intact: probing skips it but does not stop.
- Would replacing the dict with `collections.OrderedDict` help the memory profile here?No, it would make it worse. `OrderedDict` carries an additional doubly linked list over its entries, so it costs more per item than a plain dict for the same contents. The problem is retention of a peak-sized structure, which no mapping type fixes on its own; bounding the size or rebuilding after the peak is the actual remedy.
It is a cloakroom that keeps every numbered peg it has ever needed: taking coats away frees the rail, but nobody takes the pegboard down until the whole room is rebuilt.
saying these in an interview costs you the question
- Says deleting keys returns the table memory immediately
- Expects `gc.collect()` to shrink the dict
- Thinks a dict halves itself below some load factor
- Believes new inserts reuse the deleted entry slots
- Blames a reference leak without measuring the container
- Assumes swapping the mapping type reclaims the table