Closures and Scope
Closures and Scope
A nested function can remember names from the function that created it. That memory is a closure. The boring default is a small factory: make_taxer(rate) returns a function that always uses that rate. The trap is a loop: every nested function sees the last value of the loop variable unless you bind it as a default argument.
Mental model
Python looks up a name in this order: local, enclosing function, global (module), then built-ins. That is LEGB. A nested def that reads a name from the enclosing function is a closure. The inner function stores a cell pointing at that name, not a snapshot of the value at def time.
Late binding means the value is read when the inner function runs, not when it was defined. In a loop, that name is the last assignment.
The usual fix is a default argument: def inner(n=n):. Defaults are evaluated at def time, so each inner function gets its own copy.
nonlocal lets the inner function assign to a name in the enclosing function. Use it for a tiny counter. Do not use it as a hidden global.
Worked examples
Case 1: A factory that closes over a rate
Save as make_taxer.py. Each returned function remembers its own rate.
# make_taxer.py
def make_taxer(rate):
def with_tax(cents):
return round(cents * (1 + rate))
return with_tax
def main():
desk = make_taxer(0.1)
catering = make_taxer(0.0)
print(desk(400))
print(catering(400))
print(desk.__name__)
if __name__ == "__main__":
main()Run:
uv run python make_taxer.pyOutput:
440
400
with_tax
desk and catering are two functions. They share code, not data.
Case 2: nonlocal for a running count
Save as shift_counter.py. bump assigns to n in make_counter. Without nonlocal, that assignment would create a new local n and the outer n would never change.
# shift_counter.py
def make_counter():
n = 0
def bump():
nonlocal n
n += 1
return n
return bump
def main():
tickets = make_counter()
print(tickets())
print(tickets())
other = make_counter()
print(other())
print(tickets())
if __name__ == "__main__":
main()Run:
uv run python shift_counter.pyOutput:
1
2
1
3
Two counters do not share n. That is the point of a closure: state that is not a global and not a class — yet.
If you need to inspect the count, name it on an object. A closure with nonlocal is easy to overgrow.
Case 3: Late binding in a loop (the bug, then the default-arg fix)
Save as late_tables.py. make_buggy appends three functions in a loop. Each function reads n. When they run, the loop is finished, so n is 12 for all three.
make_fixed binds n as a default: def open_it(n=n). Defaults freeze the value at def time. Each function keeps the table number from that iteration.
# late_tables.py
def make_buggy(tables):
fns = []
for n in tables:
def open_it():
return f"open table {n}"
fns.append(open_it)
return fns
def make_fixed(tables):
fns = []
for n in tables:
def open_it(n=n):
return f"open table {n}"
fns.append(open_it)
return fns
def main():
tables = [3, 7, 12]
print("buggy:")
for fn in make_buggy(tables):
print(fn())
print("fixed:")
for fn in make_fixed(tables):
print(fn())
if __name__ == "__main__":
main()Run:
uv run python late_tables.pyOutput:
buggy:
open table 12
open table 12
open table 12
fixed:
open table 3
open table 7
open table 12
The inner parameter n shadows the loop variable. That is deliberate. Callers still write fn() with no arguments; they never pass n.
A second honest fix is a factory: def make_open(n): def open_it(): return ...; return open_it and append make_open(n). Same idea: bind n as a parameter of a new call. The default-arg form is the one-liner you will see in reviews.
The trap
The trap is Case 3. People write the buggy loop because it looks like the function captured 3, then 7, then 12. It captured the name n. Read late_tables.py from the top. If you only remember one trick from this chapter, remember n=n.
A cousin of the same bug: a list of lambdas, lambda: n, inside a for. Same late binding. Same default-arg fix: lambda n=n: n.
The boring rule
- Use a closure as a tiny factory (
make_taxer(rate)), not as a hidden object model. - Lookup is LEGB. Assignment is local unless you write
nonlocalorglobal. - Prefer
nonlocalonly for a small counter or flag. A class is clearer once you have two pieces of state. - Never build functions in a loop that close over the loop variable without binding it (
n=nor a factory). - Do not use
globalto pass data into a function. Pass an argument.
Try this
- In
make_taxer.py, addmake_discounter(percent)that returns a function subtracting that percent from cents. Printmake_discounter(10)(400). - In
shift_counter.py, add a nestedresetthat setsnback to 0 withnonlocal. Callbump,reset,bump. - In
late_tables.py, addmake_lambda(tables)that useslambda n=n: f"open table {n}". Confirm it matchesmake_fixed. - Comment out
n=ninmake_fixed(emptydef open_it():) and run it. You should see the buggy output again.