A closure is a function that remembers the variables from the enclosing scope where it was created. When you define a function inside another function and reference a name from the outer function, Python captures that binding; the inner function keeps working even after the outer one has returned. Closures are how Python supports “little functions with state” without forcing you to define a class.
The simplest example is a maker function: def make_adder(n): def add(x): return x + n; return add. Each call to make_adder returns a fresh add that carries its own n. You can have add5 = make_adder(5) and add10 = make_adder(10), and they don't interfere with each other.
Closures become more interesting when the captured variable needs to be modified. Writing count = 0 in an outer function and then doing count += 1 in an inner function creates a local count inside the inner function and raises UnboundLocalError. Use the nonlocal keyword to tell Python “I mean the one in the enclosing function”, and the closure will update it in place.
Closures show up all over Python. Decorators use them to hold the wrapped function. Callbacks use them to preserve context. functools.partial is essentially a closure in a class. Understanding them demystifies a huge chunk of “magical-looking” code.
Capturing and nonlocal
Read access to an enclosing variable is automatic. Write access needs nonlocal name at the top of the inner function. Without it, assignment creates a new local and shadows the outer binding.
global name is the module-scope equivalent; use it sparingly. Mutable containers (lists, dicts) can be mutated through a closure without nonlocal, because mutation doesn't rebind the name.
The late-binding pitfall
A classic gotcha: fs = [lambda: i for i in range(3)] produces three functions that all return 2, because they share the same i, and by the time you call them, i is 2. Fix with a default argument: lambda i=i: i freezes the current value.
This matters when building functions in a loop — for example, registering handlers. Always capture loop variables explicitly or use a helper function.
Closure-related tools.
| Tool | Purpose |
|---|---|
nonlocal namestatement | Rebind a name in an enclosing function. |
global namestatement | Rebind a name at module scope. |
fn.__closure__attribute | Tuple of captured cell objects. |
functools.partialfunction | Closure-like pre-binding of arguments. |
lambda ...syntax | Inline closure-capable function. |
inspect.getclosurevarsfunction | Inspect a function's captured variables. |
class closurepattern | Nested classes capture enclosing function scope. |
PEP 3104spec | Nonlocal statement. |
Using Enclosed Code Scopes code example
The script builds a counter via a closure, demonstrates nonlocal, and fixes the late-binding trap.
# Lesson: Using Enclosed Code Scopes
def make_adder(n: int):
def add(x: int) -> int:
return x + n
return add
add5 = make_adder(5)
add10 = make_adder(10)
print("add5(3):", add5(3))
print("add10(3):", add10(3))
def make_counter(start: int = 0):
count = start
def tick() -> int:
nonlocal count
count += 1
return count
return tick
c = make_counter()
print("ticks:", c(), c(), c()) # 1 2 3
# Late-binding trap
funcs_broken = [lambda: i for i in range(3)]
print("broken:", [f() for f in funcs_broken]) # [2, 2, 2]
# Fix: default argument captures the current value
funcs_fixed = [lambda i=i: i for i in range(3)]
print("fixed :", [f() for f in funcs_fixed]) # [0, 1, 2]
# Closure over a mutable list: mutation OK without nonlocal
def build_logger():
messages: list[str] = []
def log(msg: str) -> None:
messages.append(msg)
def dump() -> list[str]:
return list(messages)
return log, dump
log, dump = build_logger()
log("one"); log("two")
print("log dump:", dump())
# Inspect captured variables (useful for debugging decorators)
import inspect
print("add5 closes over:", inspect.getclosurevars(add5).nonlocals)
Three closure habits:
1) Read-only capture is automatic; no keyword needed.
2) Mutating a name requires `nonlocal` (for an enclosing function) or `global` (for module).
3) Mutating a list inside a closure works without nonlocal — mutation isn't rebinding.
4) Default argument (`lambda i=i:`) is the canonical fix for late binding.
Build a small state machine using closures.
def traffic_light():
state = "red"
order = {"red": "green", "green": "yellow", "yellow": "red"}
def step():
nonlocal state
state = order[state]
return state
def current():
return state
return step, current
step, current = traffic_light()
print(current(), step(), step(), step(), step())
Verify closure capture and nonlocal.
def outer():
x = 1
def inner():
nonlocal x
x += 1
return x
return inner
f = outer()
assert f() == 2 and f() == 3
def broken():
fs = [lambda: i for i in range(3)]
return [fn() for fn in fs]
assert broken() == [2, 2, 2]
Running prints:
add5(3): 8
add10(3): 13
ticks: 1 2 3
broken: [2, 2, 2]
fixed : [0, 1, 2]
log dump: ['one', 'two']
add5 closes over: {'n': 5}