Skip to content
QSWEQB
hardConceptScenarioMidSeniorStaff

A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?

CPython frees an object the instant its reference count reaches zero, so steady growth is usually something still holding a reference rather than a leak. The cycle collector reclaims only reference cycles. Measure with tracemalloc first, then use __slots__ or generators to cut per-object and peak cost.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Whether reference counting is named as the primary mechanism and the cycle collector as a supplement to it
  • Do they ask what still holds the reference before proposing a fix
  • That the difference between total allocation and peak allocation is understood, because generators only affect the second
  • Does the candidate know sys.getsizeof reports a shallow size and say so before quoting a number from it
  • Can they explain why resident memory can stay high after Python has genuinely freed the objects

Answer

Two mechanisms, and only one of them is a collector

CPython's primary memory management is reference counting. Every object carries a count of the references to it, the count is adjusted as names are bound and dropped, and the object is deallocated the moment it reaches zero. This is immediate and deterministic, which is why with open(...) releasing a file handle at the end of the block is reliable and why most Python code never thinks about memory at all.

Reference counting has exactly one blind spot: a group of objects that reference each other keeps every count above zero even when nothing outside the group can reach them. That is what the cyclic garbage collector in the gc module exists for. It walks container objects — objects capable of holding references to others — looking for groups whose only remaining references come from inside the group, and frees them. It never has to consider a str, int or float, because those cannot participate in a cycle.

Knowing which mechanism you are talking about matters diagnostically, because the two produce different symptoms. A cycle produces memory that is freed eventually, in bursts, when a collection runs. A live reference produces memory that is never freed at all, and no collector is going to help you, because nothing is wrong — you told Python to keep it.

Classify the growth before you touch anything

The first question is not "where is the leak" but "is this growth or is this reuse". A process whose resident size rises for an hour and then flattens is a memory allocator reaching steady state, and there is nothing to fix. A process whose resident size steps up with every request and never retreats is retention.

For retention, the overwhelmingly common causes in a service are ordinary and boring. A module-level dict used as a cache with no eviction. A list that accumulates request or response objects for metrics. A closure or a default argument holding onto the first value it ever saw. A logging handler or an exception object keeping a whole traceback, and therefore every frame's locals, alive. Notice that reference counting handles all of these perfectly: the objects are reachable, so they are kept. The bug is in the design of the reference, not in the runtime.

Attributing bytes to lines

tracemalloc is in the standard library and gives you allocation sites, which is what you need. Take a snapshot when the process has warmed up, take a second one later, and diff them.

import tracemalloc

tracemalloc.start(10)              # keep 10 frames so the call site is readable
before = tracemalloc.take_snapshot()

cache = []
for i in range(100_000):
    cache.append({"id": i, "payload": "x" * 64})

after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:3]:
    print(stat)                    # size delta, attributed to the allocating line

Two limits are worth volunteering. tracemalloc only sees allocations made through CPython's own allocator, so memory a native extension takes directly from the system allocator does not appear in its totals, which is why a growing process can show a flat tracemalloc profile. And tracing costs real overhead on every allocation, so you enable it deliberately rather than leaving it on in production.

For "what is still holding this", gc.get_referrers(obj) gives you the objects pointing at it, which is usually enough to identify the offending container in one step.

Per-object overhead, and what slots buys

Once you know the objects are legitimately live and simply too expensive, the shape of the object becomes worth attacking. By default every instance stores its attributes in a per-instance __dict__. CPython already optimises this heavily — instance dictionaries share their key table with other instances of the same class under PEP 412, and Python 3.11 made them lazier still — so the saving from __slots__ is real but smaller than advice written for Python 2 implies.

__slots__ replaces that dictionary with a fixed set of descriptor-backed slots. You lose the ability to set attributes that were not declared, you lose __weakref__ unless you list it, and you gain a per-instance saving that only matters when you are holding hundreds of thousands of instances.

import sys

class WithDict:
    def __init__(self, x, y):
        self.x, self.y = x, y

class WithSlots:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

a, b = WithDict(1, 2), WithSlots(1, 2)
# getsizeof is shallow: the instance dict is a separate object and must be added.
print(sys.getsizeof(a) + sys.getsizeof(a.__dict__), sys.getsizeof(b))

The failure mode to know about: a subclass that does not itself declare __slots__ gets a __dict__ again, and the whole saving evaporates for every instance of that subclass. Slots are a property of the entire chain, not of one class.

Generators move the peak, they do not shrink the total

A generator does not reduce how many objects you allocate. It reduces how many exist simultaneously. Streaming a million rows through a generator pipeline allocates a million rows either way; the difference is whether all million are alive at once. So generators fix peak memory, which is what kills a container with a memory limit, and do nothing at all for total allocation, which is what drives collector pressure.

They also stop helping the instant you need the data twice, need len(), or need to index into it — at which point you materialise the sequence and are back where you started, having added laziness for nothing. And a generator that is abandoned part-way holds every local in its frame alive until it is closed or collected, which occasionally makes memory worse rather than better.

Where this investigation usually goes wrong

The mistake is optimising object layout before establishing retention. Adding __slots__ to a class whose instances are being accumulated in an unbounded cache makes each retained object somewhat cheaper and leaves you with the same unbounded growth, a week later, with a class you can no longer add attributes to. Establish what holds the reference, bound it, and only then reduce the per-object cost.

The related trap is trusting the wrong number. Resident set size is what your monitoring shows and it is not the number you are debugging. CPython serves small objects from pools carved out of arenas, and an arena is returned to the operating system only when every pool inside it is free, so a single surviving object can pin a whole arena. Free half your objects and RSS may not move at all. Judge whether you fixed the problem by watching Python-level allocation, from tracemalloc or gc, and use RSS only to confirm the trend over a much longer window.

Likely follow-ups

  • What kinds of object does the cyclic collector track, and which ones can it ignore entirely?
  • How would you find what is still referencing an object you expected to be freed?
  • Why might tracemalloc show almost nothing while the process keeps growing?
  • When would you tune the generational collector's thresholds, or call gc.freeze before forking workers?

Related questions

Further reading

memoryreference-countinggarbage-collectionslotsprofiling