skip to content

Do two Python inner functions that close over the same name share one cell?

level: middleimportance: should knowfreq 28%

answer

  1. Ask what the boundary really is
  2. Definition time or call time?
  3. One box, two holders
  4. Identity on the cells, not the values
  5. A second call allocates again

basics

~20 s

Yes, when both were defined in the same call of the enclosing function: they receive the identical cell object, so a nonlocal write in one is immediately visible to the other. Two separate calls to that enclosing function create independent cells.

solid answer

~40 s

The cell is created once per **execution** of the enclosing function, not once per nested `def`. Every nested function defined in that call receives a reference to the same cell object, so `f.__closure__[0] is g.__closure__[0]` is `True` and a `nonlocal` write inside one of them changes what the other reads — the write goes into the cell rather than rebinding a private copy. That is what lets a pair of nested functions act as a writer and a reader over shared state. Cross-call there is no sharing at all: each call of the enclosing function allocates fresh cells, so two closures produced by two calls are fully independent. The mutation is not atomic, so if such a closure pair is driven from several threads the state needs a lock of its own.

code

python · 16 lines
python
def make_pair():
    total = 0
    def add(n):
        nonlocal total
        total += n
    def read():
        return total
    return add, read

add, read = make_pair()
add(3)
print(read())                                    # 3
print(add.__closure__[0] is read.__closure__[0])  # True

add2, read2 = make_pair()
print(read2())                                   # 0, independent cell

go deeper

for a junior

Remember the rule in one line: functions defined in the same call share the captured slot, and a fresh call gives fresh slots. That alone prevents most confusion about factory functions.

for a middle

Explain why the write is visible — the assignment stores into the cell rather than rebinding a name — and how to demonstrate the sharing with an identity check on the cells rather than on their contents.

for a senior

Show judgement about the consequences: aliasing between closures handed to different subsystems, the lack of atomicity on a read-modify-write, and the fact that a group of closures is a single unit of retention.

for a principal

Own the boundary between closure state and an object. Once several callables share mutable state, the invisible-to-introspection, hard-to-test properties usually outweigh the brevity, and standardising on a small class is the maintainable call.

## One cell per call, not per definition When the compiler decides that a local of an enclosing function must become a cell, it records the name in that function's `co_cellvars`. The cell object itself is created at **runtime**, when the frame for a particular call is set up. Every nested function object built during that call is handed a reference to the same cell. So sharing follows call boundaries exactly: * Two nested functions defined in the **same call** — same cell, shared state. * Two closures returned by **two calls** of the same enclosing function — different cells, no relationship whatsoever, even though they share a code object. You can check this directly with identity: compare `f.__closure__[i] is g.__closure__[i]` for the index of the name in `co_freevars`. Identity on the *cells*, not equality on their contents, is the right test; two independent cells can easily hold equal values. ## Why the write is visible The key point is that a `nonlocal` assignment does not rebind a name in the nested function. It stores into the cell. Because the cell is the single piece of storage that both nested functions reach through, the reader does not need to be told anything: the next time it loads its free variable, it loads from the same slot the writer just filled. Nothing is copied, nothing is snapshotted, and there is no notification mechanism — there is one box, and both functions hold it. This makes a small, disciplined pattern possible: a factory that returns two or more functions which together operate over private state that no caller can reach except through them. The state is genuinely encapsulated — there is no attribute to poke at — but it is also invisible to normal introspection, which is the tradeoff. ## What the sharing costs you Three consequences show up in real code. **Aliasing surprises.** If you build a set of closures inside one call and hand them to different subsystems, they are not independent, however independent they look. A change one of them makes is a change all of them see. Reviewers reading a call site see several function objects and naturally assume separate state. **Thread safety.** Reading a cell and writing it back — which is what an augmented assignment through `nonlocal` does — is a read-modify-write, and it is not atomic. Under the free-threaded build, which shipped experimentally in 3.13 and became officially supported in 3.14 (PEP 779), that gap is genuinely concurrent rather than merely interleaved between bytecodes. Closure state shared across threads needs its own `threading.Lock` exactly like any other mutable shared object. **Lifetime coupling.** The cell lives as long as *any* of the functions holding it lives. Dropping one of a pair of closures frees nothing if the other is still parked in a registry, and whatever the cell contains stays reachable with it. A pair of closures is a single unit of retention. ## The frame is not what is shared A common wrong mental model is that the nested functions share the enclosing *frame*, and that the frame is somehow kept alive. It is not. The frame is discarded when the enclosing call returns; only the cells it created survive, kept alive by the function objects that reference them. That is why sharing is per-name and not wholesale: a local of the enclosing function that no nested function uses never becomes a cell and is simply gone after the call. Two nested functions that close over *different* names of the same enclosing call share nothing at all, even though they came from the same frame. ## When to reach for something else If you find yourself returning three or four functions over shared cells, you have written a class with extra steps and worse ergonomics: the state cannot be inspected, printed, pickled or subclassed, and a debugger shows only opaque cells. A small class with plain attributes says the same thing and is far easier for the next reader to reason about. Shared cells are at their best for one narrow purpose — a tiny amount of private state behind one or two callables — and get worse as that state grows.

  • How would you prove in a REPL that two closures share a cell?
    Compare the cells by identity, not their contents: `f.__closure__[i] is g.__closure__[i]`, where `i` is the position of the name in `f.__code__.co_freevars`. Equal `cell_contents` proves nothing, since two independent cells can hold the same value. Then write through one closure and read through the other to confirm the effect end to end.
  • Does the shared cell disappear when the enclosing function returns?
    No. The frame is discarded, but the cell is an ordinary reference-counted object kept alive by every function object that holds it. It lives until the last of those functions is released — so a pair of closures is one unit of retention, and dropping only one of them frees nothing.
  • Is incrementing shared closure state from several threads safe?
    No. An augmented assignment through `nonlocal` is a read-modify-write on the cell and is not atomic; under the free-threaded build, officially supported since 3.14, the interleaving is genuinely concurrent. Guard it with a `threading.Lock`, or use a counter designed for concurrent updates instead of closure state.

saying these in an interview costs you the question

  • Says each nested def gets its own private cell
  • Claims the nested functions share the enclosing frame
  • Thinks two calls of the factory share state
  • Compares cell_contents instead of cell identity
  • Assumes a nonlocal increment is atomic
  • Believes dropping one closure frees the shared state

context