Using Enclosed Code Scopes

A closure is a function that remembers the variables from the enclosing scope where it was created. When you define a function inside another function and reference a name from the outer function, Python captures that binding; the inner function keeps working even after the outer one has returned. Closures are how Python supports “little functions with state” without forcing you to define a class.

The simplest example is a maker function: def make_adder(n): def add(x): return x + n; return add. Each call to make_adder returns a fresh add that carries its own n. You can have add5 = make_adder(5) and add10 = make_adder(10), and they don't interfere with each other.

Closures become more interesting when the captured variable needs to be modified. Writing count = 0 in an outer function and then doing count += 1 in an inner function creates a local count inside the inner function and raises UnboundLocalError. Use the nonlocal keyword to tell Python “I mean the one in the enclosing function”, and the closure will update it in place.

Closures show up all over Python. Decorators use them to hold the wrapped function. Callbacks use them to preserve context. functools.partial is essentially a closure in a class. Understanding them demystifies a huge chunk of “magical-looking” code.

Capturing and nonlocal

Read access to an enclosing variable is automatic. Write access needs nonlocal name at the top of the inner function. Without it, assignment creates a new local and shadows the outer binding.

global name is the module-scope equivalent; use it sparingly. Mutable containers (lists, dicts) can be mutated through a closure without nonlocal, because mutation doesn't rebind the name.

The late-binding pitfall

A classic gotcha: fs = [lambda: i for i in range(3)] produces three functions that all return 2, because they share the same i, and by the time you call them, i is 2. Fix with a default argument: lambda i=i: i freezes the current value.

This matters when building functions in a loop — for example, registering handlers. Always capture loop variables explicitly or use a helper function.

Closure-related tools.

ToolPurpose
nonlocal name
statement
Rebind a name in an enclosing function.
global name
statement
Rebind a name at module scope.
fn.__closure__
attribute
Tuple of captured cell objects.
functools.partial
function
Closure-like pre-binding of arguments.
lambda ...
syntax
Inline closure-capable function.
inspect.getclosurevars
function
Inspect a function's captured variables.
class closure
pattern
Nested classes capture enclosing function scope.
PEP 3104
spec
Nonlocal statement.

Using Enclosed Code Scopes code example

The script builds a counter via a closure, demonstrates nonlocal, and fixes the late-binding trap.

# Lesson: Using Enclosed Code Scopes
def make_adder(n: int):
    def add(x: int) -> int:
        return x + n
    return add


add5 = make_adder(5)
add10 = make_adder(10)
print("add5(3):", add5(3))
print("add10(3):", add10(3))


def make_counter(start: int = 0):
    count = start
    def tick() -> int:
        nonlocal count
        count += 1
        return count
    return tick


c = make_counter()
print("ticks:", c(), c(), c())   # 1 2 3


# Late-binding trap
funcs_broken = [lambda: i for i in range(3)]
print("broken:", [f() for f in funcs_broken])   # [2, 2, 2]

# Fix: default argument captures the current value
funcs_fixed = [lambda i=i: i for i in range(3)]
print("fixed :", [f() for f in funcs_fixed])   # [0, 1, 2]


# Closure over a mutable list: mutation OK without nonlocal
def build_logger():
    messages: list[str] = []
    def log(msg: str) -> None:
        messages.append(msg)
    def dump() -> list[str]:
        return list(messages)
    return log, dump


log, dump = build_logger()
log("one"); log("two")
print("log dump:", dump())


# Inspect captured variables (useful for debugging decorators)
import inspect
print("add5 closes over:", inspect.getclosurevars(add5).nonlocals)

Three closure habits:

1) Read-only capture is automatic; no keyword needed.
2) Mutating a name requires `nonlocal` (for an enclosing function) or `global` (for module).
3) Mutating a list inside a closure works without nonlocal — mutation isn't rebinding.
4) Default argument (`lambda i=i:`) is the canonical fix for late binding.

Build a small state machine using closures.

def traffic_light():
    state = "red"
    order = {"red": "green", "green": "yellow", "yellow": "red"}
    def step():
        nonlocal state
        state = order[state]
        return state
    def current():
        return state
    return step, current

step, current = traffic_light()
print(current(), step(), step(), step(), step())

Verify closure capture and nonlocal.

def outer():
    x = 1
    def inner():
        nonlocal x
        x += 1
        return x
    return inner
f = outer()
assert f() == 2 and f() == 3
def broken():
    fs = [lambda: i for i in range(3)]
    return [fn() for fn in fs]
assert broken() == [2, 2, 2]

Running prints:

add5(3): 8
add10(3): 13
ticks: 1 2 3
broken: [2, 2, 2]
fixed : [0, 1, 2]
log dump: ['one', 'two']
add5 closes over: {'n': 5}