Closures#
A closure is a function that retains access to free variables from its enclosing lexical scope after the outer function has returned. Closures power decorators, factories, callbacks, and lightweight state — without classes or globals.
How to use this page
Prerequisites: Functions and Scope. Next: Decorators, Partial Functions.
At a glance
| Track | Python Advanced → Advanced Functions |
| Sections | 7 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: Definition and mechanics · Closures vs classes — when which? · Stateful closures with nonlocal · Late binding trap (critical) · Memoization with closures · Closures and lambdas · Inspection and debugging
- Definition and mechanics
- Closures vs classes — when which?
- Stateful closures with
nonlocal - Late binding trap (critical)
- Memoization with closures
- Closures and lambdas
- Inspection and debugging
Definition and mechanics#
def outer(msg: str):
def inner():
print(msg) # msg is a free variable
return inner
greet = outer("Hello")
greet() # Hello — outer has finished, inner still sees msg
A function is a closure when it references variables from an enclosing scope that are not local parameters or globals:
| Term | Meaning |
|---|---|
| Free variable | Used in inner fn, defined in outer fn |
| Cell | Indirection holding closed-over value |
nonlocal |
Write to enclosing (non-global) variable |
Closures vs classes — when which?#
| Use closure | Use class |
|---|---|
| Small state + 1–2 behaviors | Many methods, complex invariants |
| Factory returning customized fn | Need inheritance / polymorphism |
| Decorator internals | Rich API surface |
| Callback with captured context | Multiple instances with identity |
# Closure factory
def make_multiplier(n: int):
def multiply(x: int) -> int:
return x * n
return multiply
double = make_multiplier(2)
# Class equivalent
class Multiplier:
def __init__(self, n: int):
self.n = n
def __call__(self, x: int) -> int:
return x * self.n
Both work — closures are lighter for simple cases.
Stateful closures with nonlocal#
def make_counter(start: int = 0):
count = start
def increment(step: int = 1) -> int:
nonlocal count
count += step
return count
def get() -> int:
return count
return increment, get
inc, get = make_counter(10)
inc() # 11
get() # 11
Each make_counter() call creates a new count cell — independent counters.
Read-only closure (no nonlocal)#
def make_logger(level: str):
def log(msg: str) -> None:
print(f"[{level}] {msg}")
return log
info = make_logger("INFO")
info("started")
Capturing is read-only unless you assign — then nonlocal is required.
Late binding trap (critical)#
Closed-over variables are resolved at call time, not definition time:
# BUG
funcs = []
for i in range(3):
funcs.append(lambda: i)
[f() for f in funcs] # [2, 2, 2]
# FIX 1 — default arg binds at def time
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
# FIX 2 — factory
def make_fn(i):
return lambda: i
funcs = [make_fn(i) for i in range(3)]
# FIX 3 — functools.partial
from functools import partial
funcs = [partial(lambda i: i, i) for i in range(3)]
Same trap hits decorators and event handlers created in loops.
Memoization with closures#
Manual cache before @cache:
def memoize(fn):
cache: dict = {}
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
@memoize
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
Production: functools.cache / lru_cache — see Decorators.
Requirement: arguments must be hashable for dict keys — use tuples not lists.
Closures and lambdas#
Lambdas in closures are common for short returned functions — named def when debugging matters (stack traces show <lambda>).
Inspection and debugging#
def outer(x):
def inner(y):
return x + y
return inner
f = outer(10)
f.__closure__
f.__code__.co_freevars # ('x',)
inspect.getclosurevars(f) # requires import inspect
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
| Loop lambda | All see final i |
Default arg or factory |
Forgetting nonlocal |
UnboundLocalError on write | nonlocal count |
| Mutable closure state shared | Accidental aliasing | New closure per call |
| Unhashable memo keys | TypeError | Tuple keys |
| Assuming early binding | Surprise at call time | Test loop-created closures |
Mental model checklist#
- What is stored in
__closure__? - When is
nonlocalrequired? - Why do loop lambdas return the same value?
- How does a closure differ from an object with
__call__? - Why must memo keys be hashable?
What's next#
| Topic | Page |
|---|---|
| Decorators | Decorators |
| Partial application | Partial Functions |
| Scope rules | Variable Scope and nonlocal Keywords |
| Intermediate scope | Functions and Scope |