Closures: functions that remember
A Basic Closure — Stateful Counter
Section titled “A Basic Closure — Stateful Counter”The classic example is a counter. Each call to make_counter() creates an independent count variable that only the returned counter function can see and modify:
def make_counter(start=0, step=1): count = start # captured by the inner function
def counter(): nonlocal count # nonlocal allows modifying the captured variable value = count count += step return value
return counter
counter1 = make_counter() # starts at 0, steps by 1counter2 = make_counter(10, 5) # starts at 10, steps by 5
print(counter1()) # 0print(counter1()) # 1print(counter1()) # 2
print(counter2()) # 10print(counter2()) # 15 ← independent state — counter2 does not affect counter1counter1 and counter2 are completely independent — each has its own count variable closed over from its own call to make_counter(). This is the power of closures: each call to the outer function produces a fresh, isolated state.
Closures as Function Factories
Section titled “Closures as Function Factories”A common pattern is using closures to produce specialised functions from a general template — configuring behaviour at creation time rather than call time:
def make_validator(min_val, max_val): def validate(value): # captures min_val and max_val if not (min_val <= value <= max_val): raise ValueError(f"{value} must be between {min_val} and {max_val}") return value return validate
validate_age = make_validator(0, 150)validate_score = make_validator(0, 100)validate_rating = make_validator(1, 5)
print(validate_age(25)) # 25 ✅print(validate_score(85)) # 85 ✅print(validate_rating(4)) # 4 ✅# validate_score(150) # ❌ ValueError: 150 must be between 0 and 100Each validator is a closure with its own min_val and max_val baked in. The calling code doesn’t need to pass the bounds every time — they were captured at creation.
The Classic Closure Gotcha — Late Binding in Loops
Section titled “The Classic Closure Gotcha — Late Binding in Loops”This is one of the most common Python traps. When you create functions inside a loop, all of them capture the same variable i — not the value of i at the time they were created. By the time they are called, the loop has finished and i is at its final value:
# ❌ Wrong — all functions capture the SAME variable ifuncs = [lambda: i for i in range(5)]print([f() for f in funcs]) # [4, 4, 4, 4, 4] ← all see the final i=4The fix is to capture the value at creation time by binding it as a default argument. Default arguments are evaluated immediately when the function is defined, not when it is called:
# ✅ Correct — i=i captures the current value of i at creation timefuncs = [lambda i=i: i for i in range(5)]print([f() for f in funcs]) # [0, 1, 2, 3, 4] ✅This is a subtle but important distinction:
Late binding lambda: i | Early binding lambda i=i: i | |
|---|---|---|
When is i read? | At call time | At definition time |
| Value seen | Final value of i | Value of i when created |
| Result | [4, 4, 4, 4, 4] | [0, 1, 2, 3, 4] |