Closures in Depth — Capturing State in Functions

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
Note: Python captures variables by reference, not by value. This means the closure accesses the variable at the time it is called, not at the time it was created. For loop variable capture — one of the most common closure bugs — this matters: if you create closures inside a loop, each closure captures the same loop variable, not a copy of its value at the time of creation. All closures end up sharing the final value of the loop variable.
Tip: FastAPI’s dependency injection uses closures as factory functions. When you write 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.
Warning: The late-binding closure bug in loops is one of Python’s most famous gotchas. Creating closures inside a 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

🧠 Test Yourself

You build a list of three functions in a loop: funcs = [lambda: i for i in range(3)]. What does funcs[0]() return and why?