Skip to content

Closures

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 outer

Every name lookup in Python follows one fixed order, abbreviated LEGB:

  1. Local — names assigned inside the current function
  2. Enclosing — names in any enclosing function’s local scope (this is where closures live)
  3. Global — names assigned at module level
  4. 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()

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.

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) # Hello
print(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.

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()) # 1
print(inc()) # 2
print(inc(5)) # 7
rst()
print(inc()) # 1

Without 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 value

global 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()) # 1
print(inc(5)) # 6
print(state) # {'count': 6} — visible from outside too

state["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.

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}")

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])

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
Each call creates an independent cell — hello and hi never share state
What you getBenefitCost
nonlocalExplicit, self-documenting reassignment of an enclosing variableMust 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 functionsThe state is a plain object anyone holding a reference to it can mutate from outside too — no privacy nonlocal’s cell gives you
globalLets a function update true module-level stateCouples 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 neededEasy to forget; reads oddly to someone unfamiliar with the idiom
functools.partial late-binding fixExplicit and self-documenting — freezes the argument by nameRequires an extra import functools and a named function instead of an inline lambda
  • “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: nonlocal is only needed to reassign a free variable. Reading it, or mutating an object it refers to (state["count"] += 1 on a dict), needs no declaration at all.
  • “UnboundLocalError means 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 forgetting global/nonlocal.
  • “Two closures created from the same factory function share state” — fact: each call to the factory (make_greeter("Hello") vs make_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, lambda is just the shortest way to demonstrate it.
  • Does a nested function assign to a variable from an enclosing scope without nonlocal (or the module’s global without global)? Either it is a bug (UnboundLocalError waiting 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 global used where the value could instead be threaded through function parameters and return values? Excess global usage 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.

A 'closure' in Python is:
What does this print? x = "global" def outer(): x = "enclosing" def inner(): print(x) inner() outer()
What keyword must you use to reassign a free variable from an enclosing scope inside a nested function?
What does this print? counter = 0 def bump(): counter += 1 return counter try: bump() except UnboundLocalError: print("caught it")
Why does [lambda: i for i in range(3)] produce [2, 2, 2] when called?
What does this print? def make_counter(): state = {"n": 0} def bump(): state["n"] += 1 return state["n"] return bump, state bump, state = make_counter() bump() bump() print(state["n"])