Skip to content

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

  1. Definition and mechanics
  2. Closures vs classes — when which?
  3. Stateful closures with nonlocal
  4. Late binding trap (critical)
  5. Memoization with closures
  6. Closures and lambdas
  7. 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:

greet.__closure__                    # tuple of cell objects
greet.__closure__[0].cell_contents   # 'Hello'
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#

def power(exp: int):
    return lambda base: base ** exp

square = power(2)
cube = power(3)

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#

  1. What is stored in __closure__?
  2. When is nonlocal required?
  3. Why do loop lambdas return the same value?
  4. How does a closure differ from an object with __call__?
  5. 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