Memory and objects: why an int costs 28 bytes and where the leak actually lives
Every object pays a 16-byte header: int 28 B, empty dict 64 B. getsizeof is shallow — containers count pointers, not contents. Refcounting frees instantly; cycles need generational GC. tracemalloc snapshot diffs find growth sites; most leaks are reachable unbounded caches.
The recommendation service gained 80 MB of RSS per day until the 6 GB pod OOM-killed roughly every nine days. Ops “fixed” it with nightly restarts for a quarter — until a restart collided with peak traffic and paged everyone. The hunt assumed a leak: gc.collect() was wired to a debug endpoint and freed two megabytes. Nothing was leaking in the C sense; everything was reachable. A tracemalloc snapshot diff between hour 1 and hour 30 pointed at one line: @lru_cache(maxsize=None) on score_user(user_id, context_key) — and context_key embedded a time bucket, so nearly every call minted a fresh key. Cache hit rate: 0.4%. The “memory leak” was a cache that could never evict, appending one fat Python object graph per request, forever. One bounded maxsize later, RSS flatlined at 1.9 GB and the nightly restarts were deleted.
Why Python objects are fat
Before you can find a memory leak, you need to understand what you are paying for. When you know the header cost for each type, the RSS numbers stop being mysterious and start being arithmetic.
Every Python object begins with a 16-byte header on 64-bit builds: an 8-byte reference count and an 8-byte pointer to its type. Payload comes after. The everyday numbers:
import sys
sys.getsizeof(1) # 28 — header + size + one 30-bit digit
sys.getsizeof(1.0) # 24 — header + one C double
sys.getsizeof("a") # 50 — header + flags + len + ASCII buffer
sys.getsizeof({}) # 64 — empty dict, grows in power-of-2 jumps
sys.getsizeof([]) # 56 — empty listThis is the per-object price of dynamism: every value heap-allocated, typed at runtime, reference-counted on every binding. A C double is 8 bytes in a register; a Python float is 24 bytes on the heap plus the 8-byte pointer something holds to reach it. Multiply by ten million and the dynamism bill arrives as gigabytes.
getsizeof lies shallowly
sys.getsizeof reports one object’s own allocation — for containers, that means the pointer table, not the contents. A list of one million floats: getsizeof(lst) says about 8 MB (a million 8-byte slots plus header), while the real footprint is that 8 MB plus 24 MB of float objects — 32 MB minimum, more once allocator fragmentation gets a vote. Honest sizing is recursive: walk gc.get_referents with a seen-set, or use a heap profiler instead of arithmetic.
sys.getsizeof on a list holding 1 M distinct floats returns about 8 MB. How much process memory does the list actually account for?
Refcounting, and when the cycle collector earns its keep
CPython frees an object the instant its refcount hits zero — deterministic, immediate, the reason file handles close promptly in well-written code. What refcounting cannot free is a cycle: parent and child holding references to each other never reach zero. That is the cycle GC’s job: three generations, threshold-triggered (by net allocations), survivors promoted upward. Cycles build up in object graphs with back-references — trees whose nodes point at their parent, ORM rows pointing at their session, and the classic sleeper: a stored exception keeps its traceback, the traceback keeps every frame, and each frame keeps all its locals alive.
gc.collect() is a diagnostic as much as a remedy: if you force a full collection and memory barely moves — the Hook’s two megabytes — your growth is reachable, and no garbage collector will ever save you. The leak is a data structure doing exactly what it was told.
slots revisited: the lever for object count
The data-model unit established the mechanism; here is the capacity math. A five-field plain instance costs roughly 250+ bytes once its __dict__ exists; the slotted version about 104. At 1 M objects that is ~250 MB versus ~104 MB — and at 40 M it is the difference between a pod that fits and one that OOMs. @dataclass(slots=True) makes it one flag. Slots are the right lever when object count is the problem and the field set is fixed.
Lists of objects vs array vs numpy: the order-of-magnitude table
When the data is numeric and high-cardinality, the win is to stop having objects at all:
| Representation (1 M float64 values) | Approx. memory |
|---|---|
list of Python floats | ~32 MB (8 pointer + 24 objects) |
array.array("d") | ~8 MB, contiguous |
numpy.ndarray float64 | ~8 MB, contiguous + vectorizable |
| 1 M 3-field plain objects | ~250 MB |
| 1 M 3-field slotted objects | ~104 MB |
| numpy (1 M, 3) float64 | 24 MB |
A contiguous typed buffer is 4-10x smaller and cache-friendly; the pointer-chasing list is both fat and slow to traverse. This table is also the bridge to the next lesson — numpy is a C extension someone already wrote.
tracemalloc: finding the allocation site, not the symptom
RSS tells you that memory grew; tracemalloc tells you which line allocated it. Start it early (the frame depth costs memory and CPU, so enable it for the hunt, not forever), snapshot twice, diff:
import tracemalloc
tracemalloc.start(25) # keep 25 frames per allocation site
s1 = tracemalloc.take_snapshot()
# ... 30 minutes of representative traffic ...
s2 = tracemalloc.take_snapshot()
for stat in s2.compare_to(s1, "lineno")[:10]:
print(stat) # growth ranked by allocating linepy-spy cannot do this — it samples CPU stacks, not allocations. For native + Python allocation tracking, memray is the heavier third-party option; tracemalloc is the stdlib tool that ships everywhere.
Leak patterns that are not leaks
The recurring villains are all reachable growth: module-level caches and registries that only ever append; lru_cache(maxsize=None) keyed by high-cardinality arguments; closures and stored exceptions pinning frames (and every local in them); append-only lists on long-lived singletons. True C-level leaks exist but are rare in pure Python. When RSS grows, the first question is not “what leaked” but “which data structure is doing its job too well”.
A service grows 60 MB/hour. A forced gc.collect() frees almost nothing. What does that result tell you, and what is the next tool?
- 01Walk the triage for a service whose RSS climbs steadily: which tool answers which question, and what do the possible results mean?
- 02Give the honest memory numbers: header cost, common object sizes, and the list-vs-array-vs-numpy and plain-vs-slots comparisons at 1 M items.
Python’s memory story is the per-object price of dynamism: a 16-byte refcount-plus-type header in front of every value puts an int at 28 bytes and an empty dict at 64, and every container holds pointers to heap objects rather than the values themselves — which is why getsizeof is shallow by design and a million-float list really costs ~32 MB against the ~8 MB it reports. Reclamation is two systems: refcounting frees deterministically at zero, and the generational cycle collector exists solely for reference cycles — back-referencing graphs, stored exceptions pinning whole frame chains. The diagnostic hinge is gc.collect(): if a forced full collection frees nothing, the growth is reachable and the culprit is a structure doing its job — an unbounded lru_cache, a module-level registry — which tracemalloc’s snapshot diff will pin to an allocating line, something CPU-sampling py-spy can never do. The capacity levers scale in order of magnitude: __slots__ roughly halves fixed-field objects (250 to 104 bytes on five fields), and stepping out of objects entirely into array or numpy buys 4-10x plus cache locality. Now when you see RSS climbing steadily in a service that never crashes, your first question is not “what is leaking” — it is “which data structure is doing its job too well, and where did it stop being bounded.”
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.