Why do objects appended to a module-level list never get freed in Python?
answer
- Nothing here was ever garbage
- Ask what is still reachable
- Module globals live in sys.modules
- A root that never goes away
- Bound or drain the container
basics
~20 sA module-level list stays reachable from sys.modules for the whole process, so every object it holds keeps a live reference and can never be freed. Python reclaims an object only when nothing reachable still refers to it.
solid answer
~40 sCPython frees an object when its reference count falls to zero, and the cyclic collector frees groups of objects only once it has proved that nothing reachable from the roots points into them. A module object lives in `sys.modules` until interpreter shutdown, so its globals are permanent roots: a `_seen = []` at module level pins every record ever appended, however short-lived the function that created it was. The same holds for class attributes, mutable default arguments evaluated once at `def` time, and registries filled by decorators at import. This is not a collector failure -- the objects are genuinely still reachable, so they are not garbage at all. The remedy is to bound or drain the container (an explicit `clear()`, a size-capped mapping, or deleting the registry), never to call `gc.collect()` and hope.
code
python · 13 lines_seen = []
class Sample:
def __del__(self):
print("sample freed")
def record(payload):
_seen.append(payload)
record(Sample())
print("function returned, still holding")
_seen.clear()
print("registry cleared")go deeper
Be ready to state the rule in one line: Python frees an object only when nothing still refers to it. Then point at the module-level container as the thing that still refers to it, and say the fix is to clear or bound that container.
Explain the mechanics: refcount to zero, plus a cyclic collector for unreachable cycles, and why a name in module globals is a root reachable through sys.modules. Name the disguised forms too -- class attributes, mutable defaults, decorator registries.
An interviewer expects you to diagnose rather than guess: confirm the growth is in the Python heap, identify which type is accumulating, then find the holder. Be able to say why gc.collect() is the wrong first move and what an eviction policy should look like for that cache.
Own the policy angle: every long-lived process needs a stated ceiling for each cache and someone enforcing it. Unbounded module-level state is a default that quietly degrades restart behaviour and horizontal scaling.
### Reachability, not age, decides what is freed Python's memory model has two halves. The primary mechanism is reference counting: every object carries a count of how many references point at it, and the moment that count hits zero the object is destroyed immediately and deterministically. The secondary mechanism is the cyclic collector, which exists purely to catch groups of container objects that refer to each other and so keep each other's counts above zero while nothing outside the group refers in. The critical property both mechanisms share is that they only ever free things that are **unreachable**. Neither one has any notion of "this data is old", "this data is no longer wanted", or "the function that made this has returned". If a chain of references leads from a root to your object, the object stays. ### Why module globals are roots When a module is imported, the interpreter creates a module object, executes the file's top-level code with that module's `__dict__` as the globals mapping, and stores the module in `sys.modules` under its import name. That cache entry is what makes a second `import` cheap -- and it is also what makes module globals effectively immortal. `sys.modules` is reachable from the interpreter itself, the module is reachable from `sys.modules`, the module's dict is reachable from the module, and every name bound at module level is reachable from that dict. So a module-level container is a root by construction: ```python _loaded = [] # reachable from sys.modules for the process lifetime def record(result): _loaded.append(result) # this result is now pinned, forever ``` A local variable inside `record` disappears when the frame is destroyed, dropping one reference. But `append` stored a second reference in a container that nobody will ever drop. The refcount never reaches zero, the cyclic collector correctly declines to touch a reachable object, and process RSS grows in exact proportion to how many records you have loaded. ### The same shape in disguise The module-level list is the honest, easy-to-spot version. The same retention arrives in several less obvious wrappings, and every one of them is the same bug: - **A memo dict used as a cache.** `_cache[key] = value` with no eviction is an unbounded registry. It looks like an optimisation and behaves like a leak. - **A class attribute.** `class Loader: instances = []` is bound on the class object, which is bound in module globals. - **A mutable default argument.** `def load(batch, seen=[])` evaluates `[]` once, at `def` time, and stores it in the function's `__defaults__` tuple -- which is reachable from the function, which is reachable from module globals. - **A decorator registry.** A `@register` decorator that appends the decorated function (and anything captured in its closure) to a module-level list. - **Logging or metrics buffers** that accumulate objects rather than formatted strings. ### Diagnosing it Because the objects are reachable, the diagnosis is never "why did the collector miss them" but "who is still pointing at them". The usual sequence is: confirm growth is in the Python heap rather than in an allocator or a native library; count live instances by type before and after a workload to identify **what** is accumulating; then walk backwards from one accumulating instance to find **who** holds it. A module-level registry is the outcome that turns up most often, because it is the one form of retention that no amount of scoping discipline inside functions can undo. ### Fixing it The fix is always a policy decision about the container, never a call into the collector: - Bound it. A cache with a maximum size and an eviction rule -- `functools.lru_cache` for pure functions, or an explicit capped mapping -- turns unbounded growth into a fixed ceiling. - Drain it. If the registry is per-request or per-batch scratch space, clear it at the end of the batch. - Scope it. Move state that only one call needs from module level into the call, or onto an object whose lifetime you already manage. - Delete it. Often the registry existed for a feature that no longer reads it. And say plainly in an interview that `gc.collect()` does nothing here: the objects are reachable, so they are not garbage, and forcing a collection only costs a pause.
- Would calling gc.collect() release anything held by that module-level list?No. The cyclic collector only frees objects it can prove unreachable from the roots, and a module-level list is reachable from `sys.modules` by definition. Every object in it, and everything those objects reference, stays. Calling `gc.collect()` here buys nothing but a stop-the-world pause; the only thing that releases the memory is removing entries from the container.
- How do you tell an unbounded cache apart from a genuine leak?Mechanically there is no difference -- both are reachable data nobody will drop. The distinction is intent: a cache is retention you chose and can cap, a leak is retention nobody chose. So the question I ask is whether the container has an eviction policy and a stated ceiling. If it has neither, treat it as a leak regardless of what the variable is named, because its steady-state size is whatever the workload happens to be.
- Why does a mutable default argument show exactly the same retention?The default expression is evaluated once, when the `def` statement executes, and the resulting object is stored in the function's `__defaults__` tuple. The function object is bound in module globals, so the default list is reachable for the process lifetime and accumulates across every call that does not pass the argument. The standard fix is a `None` sentinel with a fresh container built inside the body.
A module-level registry is the lost-property shelf behind a counter in a shop that never closes: nothing on it is ever thrown out, because someone deliberately put it there.
saying these in an interview costs you the question
- Claims Python's garbage collector has a bug here
- Reaches for gc.collect() to shrink a growing cache
- Thinks objects die when the creating function returns
- Believes del on a local always frees the object
- Confuses unreachable garbage with reachable-but-unwanted data
- Assumes only reference cycles cause memory growth