Closures
Nested functions
Section titled “Nested functions”Python allows you to define a function inside another function. The inner function has access to variables in the enclosing (outer) function’s scope.
def outer(): message = "Hello from outer"
def inner(): print(message) # accesses the enclosing scope
inner()
outer() # Hello from outerLEGB: how Python looks up a name
Section titled “LEGB: how Python looks up a name”Every name lookup in Python follows one fixed order, abbreviated LEGB:
- Local — names assigned inside the current function
- Enclosing — names in any enclosing function’s local scope (this is where closures live)
- Global — names assigned at module level
- Built-in — names Python provides itself (
len,print,range, …)
Python stops at the first scope where the name is found:
x = "global x"
def legb_demo(): y = "enclosing y"
def inner(): z = "local z" print("local:", z) print("enclosing:", y) # not local to inner — found in legb_demo's scope print("global:", x) # not local or enclosing — found at module level print("built-in:", len) # not any of the above — found in builtins inner()
legb_demo()Free variables and closures
Section titled “Free variables and closures”When an inner function references a variable from its enclosing scope and is then returned to the outside world,
Python creates a closure — the inner function “closes over” the variable and keeps it alive even after outer has finished executing.
def make_greeter(greeting: str): def greet(name: str) -> str: return f"{greeting}, {name}!" # greeting is a free variable return greet
hello = make_greeter("Hello")hi = make_greeter("Hi")
print(hello("Alice")) # Hello, Alice!print(hi("Bob")) # Hi, Bob!greeting is a free variable in greet — it is not defined inside greet and not a global; it lives in make_greeter’s local scope, one level up in the “E” of LEGB.
The closure keeps the value of greeting alive for as long as hello or hi exist.
__closure__ and cell contents
Section titled “__closure__ and cell contents”A closure is not magic — CPython implements it with cell objects, one per free variable, stored on the function’s __closure__ tuple. __code__.co_freevars names which variables those cells hold:
def make_greeter(greeting): def greet(name): return f"{greeting}, {name}!" return greet
hello = make_greeter("Hello")print(hello.__closure__[0].cell_contents) # Helloprint(hello.__code__.co_freevars) # ('greeting',)Each call to make_greeter creates a fresh cell — hello and hi from the earlier example have completely independent cells, which is why they remember different greetings.
The nonlocal keyword
Section titled “The nonlocal keyword”Inside a nested function, you can read a free variable without any special keyword.
But to reassign it, you need nonlocal — otherwise Python treats the name as a new local variable.
def make_counter(start: int = 0): count = start
def increment(step: int = 1) -> int: nonlocal count # tells Python: reuse the enclosing 'count' count += step return count
def reset() -> None: nonlocal count count = start
return increment, reset
inc, rst = make_counter()print(inc()) # 1print(inc()) # 2print(inc(5)) # 7rst()print(inc()) # 1Without nonlocal count, the line count += step would raise UnboundLocalError because Python sees the assignment and treats count as a local variable that has not been assigned yet.
global: the same rule, one scope further out
Section titled “global: the same rule, one scope further out”global is nonlocal’s counterpart for module-level names: it tells Python that an assignment inside a function should rebind the global name instead of creating a new local one. The same UnboundLocalError rule applies without it — Python decides a name is local to a function by scanning the whole function body for assignments, before running any of it, so even reading the name before the assignment line fails:
counter = 0
def increment_global(): global counter counter += 1
increment_global()increment_global()print(counter) # 2
def broken_increment(): counter += 1 # counter is assigned somewhere in this function... # ...so Python treats it as local for the *entire* function body
broken_increment()# UnboundLocalError: cannot access local variable 'counter' where it is not associated with a valueglobal and nonlocal solve the same problem (an assignment would otherwise create a shadowing local) at two different scope distances — global always reaches module scope directly, skipping any enclosing function scopes in between; nonlocal reaches the nearest enclosing function scope.
Closing over a mutable instead of using nonlocal
Section titled “Closing over a mutable instead of using nonlocal”Reassigning a free variable requires nonlocal, but mutating an object the closure already holds a reference to does not — this is often the simpler fix when several inner functions need to share state:
def make_counter_mutable(): state = {"count": 0} # mutable container — no nonlocal needed
def increment(step: int = 1) -> int: state["count"] += step # mutating the dict, not rebinding the name return state["count"]
return increment, state
inc, state = make_counter_mutable()print(inc()) # 1print(inc(5)) # 6print(state) # {'count': 6} — visible from outside toostate["count"] += step looks up state (read-only access to the free variable — no nonlocal needed) and then mutates the dict it points to. This trades nonlocal’s explicit reassignment for a shared mutable container — useful when multiple inner functions need to observe or reset the same state, but it also exposes that state directly to any code holding a reference to state, which nonlocal’s fully private cell does not.
Factory functions — a practical closure
Section titled “Factory functions — a practical closure”def make_validator(min_val: int, max_val: int): """Return a validator function for the given range.""" def validate(value: int) -> bool: return min_val <= value <= max_val
validate.__doc__ = f"Return True if value is in [{min_val}, {max_val}]." return validate
is_valid_age = make_validator(0, 120)is_valid_score = make_validator(0, 100)is_valid_port = make_validator(1, 65535)
tests = [ ("age 25", is_valid_age(25)), ("age 200", is_valid_age(200)), ("score 95", is_valid_score(95)), ("score 101", is_valid_score(101)), ("port 8080", is_valid_port(8080)), ("port 0", is_valid_port(0)),]for label, result in tests: print(f"{label}: {result}")Loading Python runtime (first run only)…
Depth demo: LEGB, global, mutable state, __closure__, and the late-binding fix
Section titled “Depth demo: LEGB, global, mutable state, __closure__, and the late-binding fix”import functools
# --- LEGB named ---x = "global x"
def legb_demo(): y = "enclosing y"
def inner(): z = "local z" print("local:", z) print("enclosing:", y) print("global:", x) print("built-in:", len) inner()
print("--- LEGB ---")legb_demo()
# --- global keyword ---counter = 0
def increment_global(): global counter counter += 1
print("\n--- global ---")increment_global()increment_global()print("counter:", counter)
def broken_increment(): counter += 1 # UnboundLocalError: assignment makes counter local here
try: broken_increment()except UnboundLocalError as e: print(type(e).__name__ + ":", e)
# --- closing over a mutable instead of nonlocal ---print("\n--- mutable cell instead of nonlocal ---")
def make_counter_mutable(): state = {"count": 0} # mutable container — no nonlocal needed
def increment(step=1): state["count"] += step # mutating the dict, not rebinding the name return state["count"]
return increment, state
inc, state = make_counter_mutable()print(inc())print(inc(5))print("state dict itself:", state)
# --- __closure__ / cell contents ---print("\n--- __closure__ and cell contents ---")
def make_greeter(greeting): def greet(name): return f"{greeting}, {name}!" return greet
hello = make_greeter("Hello")print(hello.__closure__[0].cell_contents)print(hello.__code__.co_freevars)
# --- late binding + partial fix ---print("\n--- late binding + functools.partial fix ---")funcs_broken = [lambda: i for i in range(3)]print("broken:", [f() for f in funcs_broken])
funcs_default_arg = [lambda i=i: i for i in range(3)]print("default-arg fix:", [f() for f in funcs_default_arg])
def identity(i): return i
funcs_partial = [functools.partial(identity, i) for i in range(3)]print("partial fix:", [f() for f in funcs_partial])Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”Each call to make_greeter allocates its own cell for greeting — the closure is the returned function plus a reference to that specific cell, not a copy of the value:
flowchart LR
subgraph call1["make_greeter(\"Hello\")"]
cellA["cell: greeting = 'Hello'"]
end
subgraph call2["make_greeter(\"Hi\")"]
cellB["cell: greeting = 'Hi'"]
end
hello["hello.__closure__[0]"] --> cellA
hi["hi.__closure__[0]"] --> cellB Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
nonlocal | Explicit, self-documenting reassignment of an enclosing variable | Must be declared before the first assignment in the function; forgetting it raises UnboundLocalError, not a silent wrong answer |
| Closing over a mutable (dict/list) | No nonlocal needed to share state across several inner functions | The state is a plain object anyone holding a reference to it can mutate from outside too — no privacy nonlocal’s cell gives you |
global | Lets a function update true module-level state | Couples the function to a specific module’s global; makes the function harder to test in isolation and to reason about from other modules |
Default-argument late-binding fix (lambda i=i: ...) | One extra =i, no imports needed | Easy to forget; reads oddly to someone unfamiliar with the idiom |
functools.partial late-binding fix | Explicit and self-documenting — freezes the argument by name | Requires an extra import functools and a named function instead of an inline lambda |
Common Misconceptions
Section titled “Common Misconceptions”- “A closure captures the value a variable had when the function was defined” — fact: it captures the variable itself (the cell) — if that variable’s value changes later, every closure over it sees the new value, which is exactly why the late-binding loop gotcha happens.
- “Reading an enclosing variable requires
nonlocal, same as writing it” — fact:nonlocalis only needed to reassign a free variable. Reading it, or mutating an object it refers to (state["count"] += 1on a dict), needs no declaration at all. - “
UnboundLocalErrormeans the variable was never given a value anywhere” — fact: it means Python decided the name is local to this function (because it is assigned somewhere in the function body) and that particular execution reached a read before the local assignment ran — the exact failure mode of forgettingglobal/nonlocal. - “Two closures created from the same factory function share state” — fact: each call to the factory (
make_greeter("Hello")vsmake_greeter("Hi")) creates its own independent cell — closures from different calls never see each other’s variables unless they explicitly share a mutable object. - “The late-binding loop bug only affects
lambda” — fact: any nested function defined in a loop and capturing the loop variable has the same bug,lambdais just the shortest way to demonstrate it.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a nested function assign to a variable from an enclosing scope without
nonlocal(or the module’s global withoutglobal)? Either it is a bug (UnboundLocalErrorwaiting to happen) or the author actually wanted a fresh local variable — check which. - Are functions or lambdas being created inside a loop, closing over the loop variable, and called after the loop finishes? Check for the late-binding gotcha — every one of them will see the loop variable’s final value.
- Is shared mutable state (a dict or list) closed over by several inner functions instead of
nonlocal? Confirm that sharing is intentional and that nothing outside the closure can corrupt it unexpectedly. - Does a function read a name that is assigned later in the same function body, expecting to see a global or enclosing value first? Python decides “local” for the whole function ahead of time — this raises
UnboundLocalError, not a fallback to the outer scope. - Is
globalused where the value could instead be threaded through function parameters and return values? Excessglobalusage makes functions hard to test and to reason about without reading the whole module.
🐛 Spot the bug
def make_click_handlers(labels): handlers = [] for i, label in enumerate(labels): def handler(): print(f"Button {i}: {label} clicked") handlers.append(handler) return handlers
buttons = make_click_handlers(["Save", "Cancel", "Delete"])for click in buttons: click()Show the answer
Every `handler` closure captures the variables `i` and `label`, not the values they held on that loop iteration. By the time any `click()` is actually called — after the `for` loop inside `make_click_handlers` has already finished — `i` and `label` both hold their final iteration’s values. Verified on Python 3.14: all three calls print `Button 2: Delete clicked`, never `Button 0` or `Button 1`. The fix is to freeze the current value at definition time, either with default arguments (`def handler(i=i, label=label): …`) or with `functools.partial(handler_body, i, label)` — both evaluate `i`/`label` immediately, once per loop iteration, instead of looking them up later through the shared closure cell.