Closures, Lexical Scopes, and Factory Functions

Updated

September 7, 2026

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.py

Case 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.py

Output:

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

  1. Write a function make_prefixer(prefix: str) that returns a function that prepends prefix to any passed string.
  2. Build an accumulator function make_accumulator(initial: float = 0.0) that uses nonlocal to accumulate added numbers and return the current running total.
  3. Write a script demonstrating the late-binding trap with button or task callback functions, and implement both the default-argument fix and functools.partial fix.

Further reading

  • Python Language Reference: Section 4.1: Naming and Binding (Cell objects).
  • PEP 227: Statically Nested Scopes.
  • Python Standard Library: functools.partial.