Skip to content

Closures: functions that remember

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 1
counter2 = make_counter(10, 5) # starts at 10, steps by 5
print(counter1()) # 0
print(counter1()) # 1
print(counter1()) # 2
print(counter2()) # 10
print(counter2()) # 15 ← independent state — counter2 does not affect counter1

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


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 100

Each 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 i
funcs = [lambda: i for i in range(5)]
print([f() for f in funcs]) # [4, 4, 4, 4, 4] ← all see the final i=4

The 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 time
funcs = [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: iEarly binding lambda i=i: i
When is i read?At call timeAt definition time
Value seenFinal value of iValue of i when created
Result[4, 4, 4, 4, 4][0, 1, 2, 3, 4]