skip to content

Why does a Python closure keep a large captured object alive, and how do you avoid it?

level: seniorimportance: should knowfreq 36%

answer

  1. Ask what the cell holds a reference to
  2. The frame is gone, the box is not
  3. Retention follows the function object
  4. Per object, not per attribute
  5. Bind the narrow value before the def

basics

~20 s

Each captured name lives in a cell holding a strong reference, and the cell lives as long as the function object does. A closure parked in a registry therefore pins everything it captured. Capture the small field you need instead of the whole object.

solid answer

~50 s

A closure is a function object plus a tuple of cells, and every cell holds a **strong** reference to the value it captured. That reference is only dropped when the function object itself is released, so a closure stored in a long-lived registry keeps its captured graph reachable for as long as the entry sits there. Worse, capture is per name but retention is per object: closing over one attribute of a large parsed payload captures the *payload*, not the attribute, if the inner function references the outer name. The fixes are all about narrowing what the cell holds — pull out the small field into its own local before the `def`, bind values as explicit arguments through `functools.partial` instead of capturing them, keep a `weakref` when the closure genuinely should not own the object, and delete the registry entry when the work completes.

code

python · 13 lines
python
def make_rollback(payload):
    event_id = payload["id"]          # capture the field, not the payload
    def rollback():
        return f"rolling back {event_id}"
    return rollback

pending = {}
event = {"id": "evt-1", "raw": bytearray(1_000_000)}
pending[event["id"]] = make_rollback(event)

cell, = pending["evt-1"].__closure__
print(cell.cell_contents)                  # evt-1
print(pending["evt-1"].__code__.co_freevars)  # ('event_id',)

go deeper

for a junior

Remember the one fact that drives everything else: a captured value is held by a strong reference for as long as the function object exists. Prefer capturing a small field over a whole object.

for a middle

Explain the chain — registry holds the function, the function holds cells, cells hold values — and name concrete fixes: bind the narrow value before the def, or pass it as an argument with functools.partial instead of capturing it.

for a senior

Demonstrate the diagnosis. Be ready to describe growth in a long-lived process traced to callables parked in a registry whose entries are cleared only on the success path, and the finally-based fix plus the narrowed capture.

for a principal

Own the pattern-level rule: any container of callables is a retention surface, and it needs a documented clearing contract. Decide when captured state should become an explicit object with a visible lifetime rather than something hidden in cells.

## The mechanism, stated precisely When a nested function closes over a name, the compiler moves that variable into a **cell** and gives the function object a reference to it in `__closure__`. The cell holds one ordinary strong reference. Reachability then follows a simple chain: whatever holds the function object holds the cells, and the cells hold the captured values. Nothing about the closure is weak or lazy, and the enclosing frame returning changes nothing — the frame is gone, the cells are not. That is unremarkable for a closure that is called and dropped. It becomes a memory problem exactly when the function object is stored somewhere long-lived: a dispatch table, an event-handler registry, a list of pending compensating actions, a cache of prepared callables. ## The shape it takes in practice Consider a webhook receiver that, for each event it accepts, builds a small callable to undo the work if a later step fails, and parks it in a pending-rollbacks dict until the transaction completes. The natural code closes over the parsed event so the rollback can quote an identifier in its log line. The rollback only ever touches one short string — but the cell holds the whole parsed event: the raw body, the decoded JSON, whatever the parser attached. Retention is per **object**, not per attribute. While everything succeeds promptly, nothing shows. The failure mode arrives with a partial-failure rollback path: entries that are only removed on the success path accumulate whenever a downstream step half-fails and the cleanup misses a branch. Each stuck entry pins a full payload. On a worker with a 45-second cold start, the operator's instinct is to leave processes up for a long time rather than recycle them, which is precisely the condition under which slow retention becomes visible — the same code on short-lived processes would never have shown a symptom. The tell that distinguishes this from ordinary growth is that the retained objects are not obviously referenced from anywhere in your data model. They are reachable only through a function object, and function objects rarely feature in a mental map of where data lives. ## Fixes, strongest first **Capture the narrow value.** Bind exactly what the inner function needs to its own local *before* the `def`, and reference that name inside. `event_id = payload["id"]` above the nested function means the cell holds a short string and the payload is free the moment the enclosing call ends. This is the fix that removes the problem rather than managing it, and it costs one line. **Do not capture at all — pass an argument.** `functools.partial(handler, event_id)` produces a callable with the value in its arguments rather than in a cell. That does not make the reference weaker, but it does make it visible: the retained value is a plain attribute of the partial object, obvious to anyone reading it and to anything walking the structure. **Own the lifetime of the registry entry.** Remove the entry in a `finally`, so that no failure path can leave a callable parked. Retention through closures is nearly always a symptom of a container that is easier to add to than to clear. **Hold a weak reference deliberately.** If the closure should observe an object but not own it, capture a `weakref.ref` and call it inside, handling the `None` you get once the object is gone. This is the right tool when the object has a real owner elsewhere, and the wrong one when it does not — you have then converted a memory bug into an intermittent `None`. **Restructure into an object.** When several closures share captured state, a small class makes the retained references plain attributes: visible in a debugger, easy to clear, easy to reason about. ## Two things not to do Emptying the cell from outside — `del f.__closure__[0].cell_contents` — does release the value, and the next call of that function raises `NameError`. It is a legitimate emergency lever and a terrible design; if you can reach the function to empty its cell, you can drop the function. Assuming the cycle collector will handle it is also wrong-headed. The value is not garbage: it is reachable through a live registry entry, and a reachable object is never collected. Cycles are a separate concern; this is plain retention, and only dropping the reference fixes it. ## The reviewable rule When a function object outlives the call that created it, treat its captured names as fields of a long-lived record and ask of each one whether it should be there. That single habit — applied at the `def`, in review — catches nearly all of these before they become an operational puzzle.

  • Does emptying the cell from outside release the captured object?
    Yes — `del f.__closure__[0].cell_contents` drops the reference, and the value is freed if nothing else holds it. But the closure is now broken: calling it raises `NameError` on that free variable. It is an emergency lever, not a design, and anyone able to reach the function to empty its cell could simply have dropped the function instead.
  • How does a callback registry make this worse than a one-off closure?
    A closure that is called and discarded retains nothing for long. A registry keeps the function object indefinitely, so the captured graph stays reachable until the entry is removed — and entries are usually removed only on the success path. Clearing in a `finally`, or keying the registry by something you can sweep, matters more than any micro-optimisation of what was captured.
  • Is capturing a value in a closure different from binding it as a default argument?
    The reference is equally strong, but the storage and the timing differ. A default is evaluated once when the `def` runs and lives in `__defaults__`; a captured free variable is read from its cell each call, so a later `nonlocal` write is visible. For retention both pin the object — the default is simply easier to see, since it appears in the signature.

saying these in an interview costs you the question

  • Says the captured object dies when the enclosing call returns
  • Believes cells hold weak references
  • Expects the cycle collector to free reachable objects
  • Thinks capturing one attribute retains only that attribute
  • Suggests calling the closure again to release memory
  • Treats deleting the enclosing local as sufficient

context