A closure is a function that remembers the variables from its enclosing scope even after that scope has finished executing. When Python creates a closure, it packages the function together with its captured variables — this package is the closure object. Closures are not just an academic curiosity: they are the mechanism behind Python’s decorator system, FastAPI’s dependency factory pattern, configuration-driven behaviour, and the factory functions you write to produce specialised versions of generic functions. Understanding closures deeply means the rest of this chapter — decorators and functional patterns — will feel natural rather than magical.
How Closures Work
# A closure captures variables from the enclosing (outer) function's scope
def make_multiplier(factor):
# 'factor' is a local variable in make_multiplier's scope
def multiply(number):
return number * factor # 'factor' captured from enclosing scope
return multiply # return the inner function (not its result)
double = make_multiplier(2) # factor=2 captured in double's closure
triple = make_multiplier(3) # factor=3 captured in triple's closure
print(double(5)) # 10
print(triple(5)) # 15
print(double(7)) # 14
# Inspect the closure
print(double.__closure__) # (<cell at 0x...>,)
print(double.__closure__[0].cell_contents) # 2 — the captured 'factor'
# Each call to make_multiplier() creates a NEW closure with its own 'factor'
print(double.__closure__[0].cell_contents == triple.__closure__[0].cell_contents)
# False — 2 != 3
def get_db(db_url: str): def dependency(): ... return dependency, the inner dependency function is a closure that captures db_url. FastAPI calls the outer factory once to get the dependency function, then calls the dependency function on every request. Understanding this explains why FastAPI’s Depends() works differently from direct function calls.for loop without binding the loop variable in the default argument causes all closures to share the loop variable’s final value. The fix is to use a default argument to capture the value at creation time: lambda i=i: ... or def make_f(i): return lambda: i.Late Binding — The Classic Loop Bug
# ── The bug ────────────────────────────────────────────────────────────────────
funcs = []
for i in range(5):
funcs.append(lambda: i) # all lambdas capture the SAME 'i' variable
# After the loop, i == 4
print([f() for f in funcs]) # [4, 4, 4, 4, 4] — NOT [0, 1, 2, 3, 4]!
# ── Why: all closures share the same cell for 'i' ─────────────────────────────
# At call time, they all look up the current value of 'i'
# The loop has finished, so i == 4 for all of them
# ── Fix 1: default argument captures value at definition time ─────────────────
funcs = []
for i in range(5):
funcs.append(lambda i=i: i) # i=i binds the current value as a default
print([f() for f in funcs]) # [0, 1, 2, 3, 4] ✓
# ── Fix 2: factory function ───────────────────────────────────────────────────
def make_func(n):
return lambda: n # n is captured from make_func's scope — new scope each call
funcs = [make_func(i) for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4] ✓
# ── Same bug with closures inside loops ───────────────────────────────────────
adders_wrong = [lambda x: x + i for i in range(3)] # all add 2 (final i)
adders_right = [lambda x, i=i: x + i for i in range(3)] # add 0, 1, 2 ✓
nonlocal — Modifying Captured Variables
# nonlocal allows assignment to a variable in an enclosing (not global) scope
def make_counter(start: int = 0):
count = start
def increment(step: int = 1) -> int:
nonlocal count # tell Python: 'count' is in the enclosing scope
count += step # without nonlocal, this creates a local 'count'
return count
def reset():
nonlocal count
count = start
def get() -> int:
return count # reading doesn't need nonlocal — only assignment does
return increment, reset, get
inc, rst, get = make_counter(0)
print(inc()) # 1
print(inc()) # 2
print(inc(5)) # 7
print(get()) # 7
rst()
print(get()) # 0
# ── Stateful closure example — memoisation ────────────────────────────────────
def memoize(func):
cache = {} # captured by the wrapper closure
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # 832040 — computed instantly after first call
Closures in FastAPI — Dependency Factories
from fastapi import FastAPI, Depends, HTTPException
app = FastAPI()
# ── Rate limiter factory — returns a dependency closure ───────────────────────
def rate_limiter(max_calls: int = 100, window_seconds: int = 60):
"""Factory: returns a FastAPI dependency that enforces rate limiting."""
import time
from collections import deque
call_times: deque = deque() # captured in the closure
def check_rate_limit(): # the actual dependency — a closure
now = time.time()
cutoff = now - window_seconds
# Remove old calls outside the window
while call_times and call_times[0] < cutoff:
call_times.popleft()
if len(call_times) >= max_calls:
raise HTTPException(429, "Rate limit exceeded")
call_times.append(now)
return check_rate_limit # return the closure
# Use the factory to create rate limiters with different limits
strict_limit = rate_limiter(max_calls=10, window_seconds=60)
relaxed_limit = rate_limiter(max_calls=100, window_seconds=60)
@app.get("/api/search", dependencies=[Depends(strict_limit)])
async def search():
return {"results": []}
@app.get("/api/posts", dependencies=[Depends(relaxed_limit)])
async def list_posts():
return {"posts": []}
Common Mistakes
Mistake 1 — Late binding in list comprehension closures
❌ Wrong — all functions share the same variable:
ops = [lambda x: x + i for i in [1, 2, 3]]
print(ops[0](10)) # 13 — not 11! All share final i=3
✅ Correct — bind at definition time:
ops = [lambda x, i=i: x + i for i in [1, 2, 3]]
print(ops[0](10)) # 11 ✓
Mistake 2 — Forgetting nonlocal when modifying an enclosed variable
❌ Wrong — creates a local variable instead:
def counter():
count = 0
def inc():
count += 1 # UnboundLocalError: count referenced before assignment
return inc
✅ Correct — declare nonlocal:
def counter():
count = 0
def inc():
nonlocal count
count += 1 # ✓ modifies the enclosed 'count'
return inc
Mistake 3 — Treating closures as deep copies
❌ Wrong — assuming captured mutable objects are copied:
items = [1, 2, 3]
f = lambda: items # captures REFERENCE to items, not a copy
items.append(4)
print(f()) # [1, 2, 3, 4] — not [1, 2, 3]! The list was mutated
✅ Correct — capture a copy if immutability is needed:
items = [1, 2, 3]
snapshot = items.copy()
f = lambda: snapshot # ✓ snapshot is independent of future mutations to items
Quick Reference
| Concept | Key Point |
|---|---|
| Closure creation | Inner function + captured free variables |
| Capture timing | By reference — value read at call time |
| Late binding fix | lambda i=i: i or factory function |
| Modify enclosed var | nonlocal var_name before assignment |
| Inspect closure | f.__closure__[0].cell_contents |
| Stateful closure | Mutable object (list, dict) in enclosing scope |
| FastAPI use | Dependency factory functions return closures |