A closure is often explained as "a function plus the values around it." That phrase is convenient and subtly wrong. A nested function does not generally receive a frozen snapshot of every value visible when it is defined. It resolves free names through lexical bindings, and those bindings may later point at different objects.

CPython makes this concrete with cell objects shared by related functions. The cell model explains late-bound loop callbacks, nonlocal, retained objects, and why two returned functions can observe the same changing state. But we must keep two levels separate: lexical scoping and late name resolution are Python semantics; CPython cells and bytecode are one implementation.

Version note. The language examples apply to Python 3.10 through 3.14 and were verified on CPython 3.14.7. Cell introspection shown here is documented for Python function objects, while opcode names and compiler strategy are CPython details that vary by release.

Experiment 1: definition does not freeze a value

Predict the result before running the function:

Pyodide / WebAssembly
def make_reader():
    status = "draft"

    def read():
        return status

    status = "published"
    return read


reader = make_reader()
print("[result] reader status:", reader())

It prints published, not draft. Executing def read creates a function, but the expression status in its body is not evaluated then. It is evaluated when read() runs. The nearest enclosing function scope supplies the binding, and that binding was rebound before make_reader returned.

Python determines scopes from source structure. Because status is bound in make_reader and used without a local binding in read, it is a free variable in read. The closure keeps that enclosing binding reachable after the outer call's ordinary execution has ended.

Language guarantee. Free names are resolved at runtime using the nearest enclosing function scope, then globals and builtins as applicable. Python is lexically scoped: source nesting, not the caller's frame, determines the enclosing scope.

Experiment 2: callers do not supply dynamic scope

Predict whether the message inside caller affects speak:

Pyodide / WebAssembly
message = "module"


def speak():
    return message


def caller():
    message = "caller"
    return speak()


print("[result] lexically resolved message:", caller())

It prints module. speak was defined at module level, so it has no enclosing function binding for message. Calling it from a function with a local message does not alter its environment.

"Looks up at runtime" does not mean "searches the current call stack." It means that a previously determined lexical binding is read when execution reaches the name. This distinction is essential for callbacks: moving the call site does not change what a closure means.

At module level, rebinding remains visible too:

Pyodide / WebAssembly
message = "before"
def read_global():
    return message
message = "after"
print("[result] current global message:", read_global())  # after

That function reads its module global at call time. Globals are not closure cells merely because lookup is late.

Experiment 3: the loop callback trap

What list do these callbacks produce?

Pyodide / WebAssembly
callbacks = []

for index in range(3):
    callbacks.append(lambda: index)

print("[result] late-bound callback values:", [callback() for callback in callbacks])

The result is [2, 2, 2]. At module level in this exact example, index is a global name, and every lambda reads the same final global binding. Put the loop inside a function and index becomes a shared closure binding; the visible result is still [2, 2, 2].

Pyodide / WebAssembly
def build_callbacks():
    callbacks = []
    for index in range(3):
        callbacks.append(lambda: index)
    return callbacks


print("[result] shared closure values:", [callback() for callback in build_callbacks()])
# [2, 2, 2]

The failure is often described as "lambdas capture by reference." Named nested functions behave the same way, and Python references objects everywhere, so that slogan is not precise enough. The useful statement is: all callbacks refer to one lexical binding, and they read it when called. The loop repeatedly rebinds that one name; it does not create a fresh function scope per iteration.

This commonly appears when scheduling GUI handlers, async tasks, test factories, or retry callbacks that execute after the loop has finished.

Experiment 4: freeze intentionally with a default

Predict how this version differs:

def build_callbacks():
    callbacks = []
    for index in range(3):
        callbacks.append(lambda index=index: index)
    return callbacks


callbacks = build_callbacks()
print([callback() for callback in callbacks])
print(callbacks[0].__closure__)
print(callbacks[0].__defaults__)

It prints [0, 1, 2], then None, then (0,). Default argument expressions are evaluated when the def or lambda expression executes. Each loop iteration therefore creates a function with a different default object. Inside the lambda, index is a local parameter, not a free variable.

This is intentional early binding, not special closure syntax. Name the parameter clearly when its purpose might be obscure:

Pyodide / WebAssembly
def open_item(item_id):
    return f"opening {item_id}"


handlers = []
for item_id in (10, 20, 30):
    handlers.append(lambda event, item_id=item_id: open_item(item_id))

print("[result] first frozen handler:", handlers[0](None))

The familiar mutable-default warning still applies. A default freezes which object is used, not a deep copy of that object's state. Capturing a list as a default and mutating the list remains shared mutation.

Experiment 5: a factory creates a fresh binding

A helper function can express the same decision without a hidden parameter. Predict whether the resulting closures share one binding:

def make_callback(index):
    def callback():
        return index
    return callback


callbacks = [make_callback(index) for index in range(3)]

print([callback() for callback in callbacks])
print(callbacks[0].__closure__[0] is callbacks[1].__closure__[0])

The output is [0, 1, 2] and False. Every call to make_callback creates a new invocation and a distinct binding for its parameter. Each returned function closes over the binding from its own invocation.

Use a factory when callback construction deserves a name, several values must be captured, or a default-argument trick would confuse readers. Use a default when the binding is local and obvious. functools.partial is another clear choice when the desired operation is simply pre-filling arguments to an existing callable.

Experiment 6: sibling closures share state

Predict the four results, paying attention to call order:

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    def current():
        return count

    return increment, current


increment, current = make_counter()
print(current(), increment(), increment(), current())
print(increment.__closure__[0] is current.__closure__[0])

It prints 0 1 2 2 and True. Both functions refer to the same binding from one make_counter invocation. nonlocal count tells the compiler that assignments in increment rebind the nearest enclosing function binding instead of creating a local count.

Without nonlocal, the assignment makes count local throughout increment. The right side of count += 1 would then read that local before it had a value and raise UnboundLocalError.

Pyodide / WebAssembly
def broken_counter():
    count = 0
    def increment():
        count += 1
        return count
    return increment


try:
    broken_counter()()
except UnboundLocalError as error:
    print("[error] missing nonlocal declaration:", type(error).__name__)

Scope is decided by scanning the whole block for binding operations, not by following runtime branches line by line. nonlocal must refer to an existing binding in an enclosing function scope; otherwise compilation raises SyntaxError. It never targets a module global. That is what global declares.

Experiment 7: mutation is not rebinding

Why does this code need no nonlocal?

Pyodide / WebAssembly
def make_collector():
    items = []

    def add(item):
        items.append(item)
        return tuple(items)

    return add


add = make_collector()
print("[state] collector after first add:", add("a"))
print("[state] collector after second add:", add("b"))

It prints ('a',) and ('a', 'b'). Calling items.append mutates the list reached through the existing binding. It does not assign to the name items, so items remains a free variable and no declaration is needed.

Contrast rebinding:

Pyodide / WebAssembly
def make_replacer():
    items = []

    def replace(item):
        nonlocal items
        items = [item]
        return tuple(items)

    return replace


replace = make_replacer()
print("[state] replaced items:", replace("new"))

Now the name itself is assigned and nonlocal is required. Distinguishing object mutation from name rebinding prevents two opposite mistakes: adding unnecessary nonlocal declarations, and receiving UnboundLocalError when augmented assignment actually rebinds an immutable value.

Mutable containers are sometimes used to avoid nonlocal, but that is not automatically clearer. A tiny closure with one counter can be excellent. Several operations over interdependent mutable fields usually deserve a class whose state and invariants are explicit.

Experiment 8: inspect the cell, then change the binding

CPython exposes the closure carried by a Python function. Predict which value cell_contents reports after each operation:

def make_pair():
    value = "first"

    def read():
        return value

    def write(new_value):
        nonlocal value
        value = new_value

    return read, write


read, write = make_pair()
cell = read.__closure__[0]

print(read.__code__.co_freevars)
print(cell.cell_contents)
write("second")
print(cell.cell_contents, read())

The free-variable name is ('value',). The cell first contains first, then both observations produce second. The data model documents function.__closure__ as None or a tuple of cells corresponding to function.__code__.co_freevars; a cell exposes cell_contents.

In modern Python, cell.cell_contents is writable. That is useful for debuggers and focused experiments, but editing closure internals in application code is usually an opaque dependency injection mechanism. Prefer an explicit setter, object, or argument.

The article's title uses CPython's concrete vocabulary because it forms an accurate model there: sibling closures hold the same cell, and rebinding changes the object stored in that cell. Portable code should depend on lexical behavior, not cell identity tricks.

Experiment 9: bytecode reveals the implementation

Filter a disassembly to the operations that matter:

import dis


def outer():
    value = 1
    def inner():
        return value
    value = 2
    return inner


for function in (outer, outer()):
    print(function.__name__)
    for instruction in dis.get_instructions(function):
        if any(word in instruction.opname for word in ("CELL", "DEREF", "CLOSURE")):
            print(" ", instruction.opname, instruction.argrepr)

On CPython 3.14, outer includes MAKE_CELL and STORE_DEREF; inner includes LOAD_DEREF. Depending on what you print, closure construction also involves compiler metadata and function creation machinery. The outer code stores through cell-aware operations because a nested function needs the binding; the inner code loads through its free-variable storage.

CPython 3.14 detail. MAKE_CELL, LOAD_DEREF, STORE_DEREF, code-object fields, and their exact disassembly are implementation and version details. Python 3.14 also changed other local-load opcodes, including adding borrowed-reference variants. Never assert a fixed opcode sequence in portable application tests.

The bytecode validates the model; it does not define the language rule. PyPy or a future CPython can represent closures differently while preserving lexical name resolution, shared rebinding, and function behavior.

Lifetime: closures keep reachable objects alive

A returned closure can outlive the frame that created it because the state it needs remains reachable. That is the feature. It can also be a retention bug.

import weakref


class Payload:
    pass


def make_holder():
    payload = Payload()
    reference = weakref.ref(payload)
    def get():
        return payload
    return get, reference


get, reference = make_holder()
print(reference() is get())
del get
print(reference() is None)

On CPython this commonly prints True and then True because reference counting releases the now-unreachable closure and payload immediately. Only the first result is the core closure lesson. Immediate finalization after del get is not a Python language guarantee; other implementations may collect later, and cycles or debugging references can delay CPython too.

The practical rule is portable: as long as a live closure can reach an object, that object is live. Be careful when callbacks capture request objects, large data frames, GUI trees, or self and are then stored in long-lived registries. Capture a small immutable identifier, use a weak reference where lifetime semantics require one, or unregister callbacks explicitly.

Concurrency does not disappear inside a cell

Closure state is shared mutable state when several execution paths call sibling functions. nonlocal count; count += 1 is a read-modify-write operation, not a synchronization primitive. Threads, tasks, callbacks, or reentrant code can interleave around work in the closure.

Do not infer safety from a particular CPython opcode sequence or from the traditional GIL. Python 3.13 introduced supported free-threaded CPython builds, opcode sets change, and compound operations can invoke arbitrary Python code. If an invariant spans operations, protect it with the synchronization appropriate to the concurrency model or move ownership into one task or actor.

Closures hide state more effectively than classes, which can be a benefit for tiny APIs and a cost for observability. If callers need snapshots, resets, persistence, locks, metrics, or several coordinated transitions, an explicit object is usually easier to test and operate.

Practical decisions

  • Use a closure for a small callable with a small, coherent amount of private state.
  • Use a default argument, factory call, or functools.partial when callbacks need per-iteration early binding.
  • Use nonlocal only when rebinding enclosing state is the clearest model; mutation through an existing object does not require it.
  • Prefer a class when state has several operations, invariants, lifecycle management, synchronization, or debugging needs.
  • Capture the smallest object that does the job. A closure retaining self retains everything reachable from self.
  • Do not depend on callers' locals, fixed bytecode, immediate garbage collection, or CPython's GIL for semantics.
  • In reviews, inspect delayed callbacks created in loops and ask when each free name will be read.

Exercises: test the binding

  1. Rewrite the loop callback trap with a named nested function. Confirm that replacing lambda alone changes nothing.
  2. Fix the callbacks three ways: a default parameter, a factory, and functools.partial. Compare their signatures and tracebacks.
  3. Return increment, decrement, and current closures over one integer. Verify that all three closure tuples contain the same cell object.
  4. Capture a mutable list as a default argument. Explain precisely what was early-bound and what can still change.
  5. Compile a function containing nonlocal missing with compile() and inspect the SyntaxError without breaking the rest of your script.
  6. Capture an object in a long-lived callback registry, prove that it stays reachable with weakref, then design explicit unregister behavior.

Keep this model

A closure preserves access to lexical bindings, not frozen copies of values. The nested function reads those bindings when it runs. One outer invocation can give sibling closures shared state; separate factory invocations create separate state. Defaults provide early binding because their expressions run during function creation, not because they alter closure rules.

In CPython 3.14, cells and dereference opcodes make that behavior visible. Keep them as an explanatory diagram, not a portability contract. When a callback surprises you, identify whether the name is local, free, global, or a default parameter; then ask when its binding is read and whether anything can rebind or mutate what it reaches.

Primary sources