Closures & the Cell
In short: a closure is an inner function that keeps using a variable from
the function that defined it, even after that outer call has already returned —
Python keeps the variable alive in a small cell, and the inner function holds
a reference to the cell itself, not a copy of its value. That's why a closure
sees a variable's latest value, not a snapshot from when it was defined.
Reading a captured variable is free; rebinding it (the left side of = or +=)
needs nonlocal, the same rule Fundamentals taught for global. Every call to
the outer function builds fresh cells, so two closures from two separate calls
never share state — but several closures made in the same call, or the same
loop, can end up sharing one cell, which is usually a bug and occasionally
exactly the point.

Tag log lines by channel
A function defined inside another function can read the outer function's variables — including after the outer call has finished:
By the time access(...) runs, make_tagger("access") has already returned —
its local variables should be gone. Python notices tag uses prefix and puts
it in a cell instead of discarding it; tag keeps a reference to that cell:
The cell holds the variable, not a frozen value — so if it changes before the inner function runs, the inner function sees the change:
def build():
limit = 10
def show():
return limit
limit = 99
return show
print(build()())Numbering rows as they pass
A counter that numbers rows so a bad one can be reported as "row 4812" instead
of "some row". The obvious attempt fails the same way a bare global mistake
would:
UnboundLocalErrorRun it and read the error name. Add one line so bump rebinds the enclosing count instead of creating its own.
nonlocal count tells Python the name belongs to the nearest enclosing function
that has one, so count += 1 rebinds the cell instead of creating a fresh local:
Mutating an object inside the cell is not rebinding, so it needs no
nonlocal at all:
You only need nonlocal when the name sits on the left of =, += or
similar — exactly as with global.
Scale three sensor channels
Each call to a factory runs its body again and builds fresh cells, so two counters from two separate calls share nothing:
def make_counter():
count = 0
def bump():
nonlocal count
count += 1
return count
return bump
rows = make_counter()
errors = make_counter()
rows(); rows(); rows()
errors()
print(rows(), errors())But three functions built in the same call share whatever cell that call created — which is exactly the trap that catches almost everyone once:
def build_scalers():
scalers = []
for channel in range(3):
scalers.append(lambda x: x * channel)
return scalers
print([f(10) for f in build_scalers()])Step through it and read cell_contents for each function. The numbers are
real, taken from the functions themselves:
Here is the picture the trace is describing:
Two standard fixes, and both work by giving each function its own cell:
Calling make_scaler separately for each function is the clean fix. The default
argument trick works too — it evaluates c at definition time, not call time —
but it adds a parameter a caller could override by accident.
Track call counts with a class instead
A class can hold the same state:
Rule of thumb: one operation on the remembered state, reach for a closure — it's shorter, and there's no attribute for outside code to poke at by accident. Several operations sharing one state, or code outside that needs to inspect it, write a class. Closures can share one cell between several inner functions returned together — you'll use exactly that in the exercises below — but past two or three functions, a class reads better.
Quick practice
def make_counter():
count = 0
def bump():
nonlocal count
count += 1
return count
return bump
a = make_counter()
b = make_counter()
a(); a()
print(a(), b())def make_adders():
return [lambda x: x + i for i in range(3)]
print([f(10) for f in make_adders()])🎯 Your turn
Some of Quarry's stages count in steps — a sampler that keeps every fifth row numbers them 5, 10, 15, and so on.
Write make_counter(step), a factory returning a function. Each call adds
step to a running total that starts at 0, and returns the new total. Then
write counter_readings(step, calls): build one counter and return what it
returns on each of calls calls.
counter_readings(1, 3)→[1, 2, 3]counter_readings(5, 4)→[5, 10, 15, 20]counter_readings(2, 0)→[]
Hint: the inner function rebinds the total, so it needs nonlocal. Build the
counter once, outside the loop that calls it.
Press ▶ Run, then ✓ Check. ✅
