Closures & Scope Capture
A closure is a function that remembers variables from the enclosing scope where it was defined. Closures enable factories, decorators, and callbacks - but late binding in loops is a classic footgun.
Recipe
def make_multiplier(factor: int):
def multiply(value: int) -> int:
return value * factor
return multiply
double = make_multiplier(2)
print(double(10)) # 20When to reach for this:
- Factory functions with configured behavior
- Decorators wrapping callables
- Event handlers capturing context
- Partial application without functools.partial
Working Example
from typing import Callable
def make_counter(start: int = 0) -> Callable[[], int]:
count = start
def inc() -> int:
nonlocal count
count += 1
return count
return inc
def make_handlers_bad():
return [lambda: i for i in range(3)] # late binding bug
def make_handlers_good():
return [lambda i=i: i for i in range(3)]
def tracer(label: str):
def decorate(fn):
def wrapper(*args, **kwargs):
print(f"{label}: enter")
return fn(*args, **kwargs)
return wrapper
return decorate
@tracer("api")
def ping() -> str:
return "pong"
if __name__ == "__main__":
c = make_counter(10)
print(c(), c())
print([h() for h in make_handlers_bad()])
print([h() for h in make_handlers_good()])
print(ping())What this demonstrates:
nonlocalmutatescountin enclosing function- Late binding: lambdas share variable
i- all see final value without default arg trick - Default arg
i=icaptures value at lambda definition time - Decorator factory
tracerreturns decorator closing overlabel
Deep Dive
How It Works
- Free variables - Names referenced but not defined in inner function.
- Cell objects - Enclosing scope variables stored in cells shared by closures.
- LEGB - Inner lookup checks local, enclosing, global, builtin.
- Late binding - Closure looks up variable when called, not when created.
- nonlocal vs global - nonlocal binds nearest enclosing; global module scope.
Late Binding Fix Patterns
| Pattern | Code |
|---|---|
| Default arg | lambda i=i: i |
| functools.partial | partial(fn, i) |
| Factory function | def make(i): return lambda: i |
Python Notes
def make():
funcs = []
for i in range(3):
def f(i=i):
return i
funcs.append(f)
return funcsGotchas
- Loop lambda late binding - All handlers return last index. Fix: Default arg or factory.
- Closing over mutable object - Shared list mutated by all closures. Fix: Copy per closure or immutable captures.
- Accidental global mutation - Inner function assigns without nonlocal creates local. Fix:
nonlocalorglobalexplicit. - Memory retention - Closure keeps entire enclosing scope alive. Fix: Minimize captured variables.
- Cell variable confusion in debugging - Inspect closure
__closure__when puzzled.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
functools.partial | Simple arg binding | Need nonlocal mutable state |
class with __call__ | Stateful functor | One-shot simple closure |
| lambda | Tiny one-liners | Multi-statement logic |
| default arg on def | Loop factory pattern | Already using partial |
FAQs
What is a closure?
Function plus captured environment of free variables from enclosing scope.
Why lambdas in loop broken?
All closures reference same variable i evaluated at call time - use i=i default.
nonlocal required when?
Reassigning enclosing variable - reading only needs closure without nonlocal.
closure vs global?
Closure captures specific enclosing function scope; global module-level names.
inspect closures?
func.__closure__ tuple of cells; cell.cell_contents for value debugging.
decorator factory?
Outer function takes config, returns decorator closing over config, returns wrapper.
mutable default in closure factory?
Same mutable default trap as normal functions - fresh object per call in factory body.
closure performance?
Small overhead vs plain function - negligible for typical callbacks.
async closures?
Nested async def closes over same rules - late binding applies to async lambdas too (rare).
typing Callable closures?
Return Callable[[int], int] from factory for mypy consumers.
Related
- Functions, Args & Scope - LEGB
- Writing Decorators - closure-based decorators
- functools - partial alternative
- Class vs Instance vs Static Methods - callable objects
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.