Closures, Lexical Scopes, and Factory Functions
Closures, Lexical Scopes, and Factory Functions
After reading this chapter, you will understand how closures capture enclosing lexical scopes, author factory functions, and avoid the notorious late-binding loop trap.
Mental model
A closure occurs when a nested function references a variable defined in its enclosing scope, and that outer function finishes execution. The inner function retains access to that variable even after the outer function’s stack frame has returned:
Outer Function: make_multiplier(factor=3)
Frame executes and terminates.
│
▼
Heap Memory: Cell Object
┌───────────────────────────────┐
│ cell_contents: 3 │ (Held alive by reference count)
└──────────────▲────────────────┘
│ referenced by
Inner Function: multiplier(x)
┌───────────────────────────────┐
│ __code__ : bytecode │
│ __closure__ : (cell, ) ───────┘
└───────────────────────────────┘
The captured variables are stored in CPython as cell objects inside the function’s __closure__ attribute.
Minimal example
Save as closure_demo.py:
# closure_demo.py
from collections.abc import Callable
def make_multiplier(factor: int) -> Callable[[int], int]:
"""Factory function creating specialized multiplier closures."""
def multiplier(number: int) -> int:
return number * factor
return multiplier
def main() -> None:
double = make_multiplier(2)
triple = make_multiplier(3)
print(f"double(10): {double(10)}")
print(f"triple(10): {triple(10)}")
# Inspect the closure cell
if double.__closure__:
cell_val = double.__closure__[0].cell_contents
print(f"double closure factor cell: {cell_val}")
if __name__ == "__main__":
main()Run via uv run python closure_demo.py:
double(10): 20
triple(10): 30
double closure factor cell: 2
Worked examples
Case 1: Stateful API Rate Limiter using nonlocal
Closures can encapsulate private state without requiring a full class:
# rate_limiter.py
from collections.abc import Callable
import time
def make_rate_limiter(max_requests: int, window_seconds: float) -> Callable[[], bool]:
request_timestamps: list[float] = []
def allow_request() -> bool:
nonlocal request_timestamps
now = time.time()
# Prune timestamps outside window
request_timestamps = [t for t in request_timestamps if now - t < window_seconds]
if len(request_timestamps) < max_requests:
request_timestamps.append(now)
return True
return False
return allow_request
if __name__ == "__main__":
limiter = make_rate_limiter(max_requests=2, window_seconds=1.0)
print("Req 1:", limiter()) # True
print("Req 2:", limiter()) # True
print("Req 3:", limiter()) # False (exceeded limit!)Run:
uv run python rate_limiter.pyCase 2: The Notorious Late-Binding Loop Trap
When creating closures in a loop, inner functions do not capture the variable’s value at that iteration; they capture the variable reference itself. By the time they are called, the loop has completed!
# late_binding_trap.py
def create_multipliers_broken():
# THE TRAP: All lambdas reference the single variable 'i'
return [lambda x: x * i for i in range(4)]
def create_multipliers_fixed():
# THE FIX: Bind 'i' as a default argument evaluated at definition time
return [lambda x, i=i: x * i for i in range(4)]
if __name__ == "__main__":
print("Broken (late binding):")
funcs_broken = create_multipliers_broken()
print([f(10) for f in funcs_broken]) # [30, 30, 30, 30] !
print("\nFixed (default argument binding):")
funcs_fixed = create_multipliers_fixed()
print([f(10) for f in funcs_fixed]) # [0, 10, 20, 30]Run:
uv run python late_binding_trap.pyOutput:
Broken (late binding):
[30, 30, 30, 30]
Fixed (default argument binding):
[0, 10, 20, 30]
Pitfalls
Pitfall 1: Retaining large memory references in closures
Because closure cells hold strong references, an inner function will keep an entire enclosing object alive in memory even if it only needs one small sub-property. Extract the needed sub-property into a local variable before closing over it.
Exercises
- Write a function
make_prefixer(prefix: str)that returns a function that prependsprefixto any passed string. - Build an accumulator function
make_accumulator(initial: float = 0.0)that usesnonlocalto accumulate added numbers and return the current running total. - Write a script demonstrating the late-binding trap with button or task callback functions, and implement both the default-argument fix and
functools.partialfix.
Further reading
- Python Language Reference: Section 4.1: Naming and Binding (Cell objects).
- PEP 227: Statically Nested Scopes.
- Python Standard Library:
functools.partial.