Python scope and the LEGB rule
Local, enclosing, global, built-in — how Python resolves a name, when global and nonlocal are needed, and the loop-variable trap.
Scope and the LEGB rule · global, nonlocal · closures over loop variables
The sentence to own: a closure captures the variable, not the value. That one fact explains the most famous "why do all my callbacks do the same thing?" bug in every language with closures, and it is a direct consequence of Day 024 — names are bindings, and a closure keeps the binding, not a snapshot.
Part 1 — LEGB
NAME LOOKUP ORDER — the first match wins, and the search STOPS there:
L — LOCAL the current function
E — ENCLOSING any enclosing function(s), innermost first
G — GLOBAL the module
B — BUILTIN len, print, list, ...
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # local
inner()
⚠ Shadowing a builtin is legal and silent: list = [1,2] then list(...) raises
TypeError: 'list' object is not callable, three functions later. id, type, list, dict,
str, input, sum, max, filter and hash are the ones people shadow.
The rule that produces UnboundLocalError
count = 0
def bump():
print(count) # ⚠️ UnboundLocalError: cannot access local variable 'count'
count += 1
WHY — and this is a COMPILE-TIME decision, not a runtime one:
If a name is ASSIGNED ANYWHERE in a function body, it is LOCAL for the WHOLE body.
⇒ the compiler decides this when compiling the function, before it ever runs.
⇒ so `print(count)` on line 1 refers to the LOCAL count — which has no value yet.
⇒ `count += 1` is a READ then a WRITE, so it triggers the rule too.
⇒ MENTAL MODEL: the function's local names are FIXED at compile time.
`dis` shows it: LOAD_FAST (local) vs LOAD_GLOBAL (module).
def bump():
global count # "assignments here mean the MODULE-level name"
count += 1
global is almost always the wrong answer — module-level mutable state is exactly the thing
that breaks when you run four workers (Day 021). Prefer returning a value, or holding state on
an object.
Part 2 — nonlocal, and closures
def counter():
n = 0
def inc():
nonlocal n # "the n in the ENCLOSING function, not global, not new-local"
n += 1
return n
return inc # inc outlives counter() — and still sees n
c = counter()
c(); c(); c() # 1, 2, 3
WHAT A CLOSURE IS, MECHANICALLY:
the enclosing variable is stored in a CELL — a small heap object.
the inner function keeps a reference to the cell in __closure__.
⇒ so the variable OUTLIVES the function call that created it,
⇒ and the closure sees LIVE UPDATES, not a snapshot.
c.__closure__ # (<cell at 0x...: int object at 0x...>,)
c.__closure__[0].cell_contents # 3 — you can watch the captured value change
c.__code__.co_freevars # ('n',)
nonlocal vs global: nonlocal binds to the nearest enclosing function scope and fails
at compile time if there is no such name — a genuinely useful safety property. global will
happily create a module-level name that did not exist.
Part 3 — The loop-variable trap
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] ← NOT [0, 1, 2]
WHY: all three lambdas closed over THE SAME VARIABLE i.
The loop finished with i == 2. Then they were called.
⇒ a closure captures the VARIABLE, not the VALUE at creation time.
⇒ if you call them DURING the loop you get 0, 1, 2 — the timing is the whole bug.
# FIX 1 — default argument: evaluated AT DEFINITION TIME (Day 025, used deliberately)
funcs = [lambda i=i: i for i in range(3)] # ⇒ [0, 1, 2]
# FIX 2 — a factory: each call gets its own scope, which is the cleaner statement of intent
def make(i): return lambda: i
funcs = [make(i) for i in range(3)]
# FIX 3 — functools.partial
from functools import partial
funcs = [partial(lambda i: i, i) for i in range(3)]
Where this bites in real code: registering callbacks in a loop, building event handlers,
scheduling tasks (asyncio.create_task(handle(x)) inside a loop), and constructing route handlers
dynamically. Every one of them fires later, which is exactly when the trap springs.
⚠ The async version is worse, because the delay is guaranteed:
for url in urls:
tasks.append(asyncio.create_task(fetch(url))) # fine — url is EVALUATED at call time
for url in urls:
tasks.append(asyncio.create_task(lambda: fetch(url)())) # ⚠️ all fetch the LAST url
Comprehension scope
# Python 3: a comprehension has its OWN scope. The loop variable does not leak.
[i for i in range(3)]
i # NameError (in Python 2 it was 2 — this was fixed deliberately)
# ⚠️ BUT: a comprehension inside a CLASS BODY cannot see the class's other names:
class C:
vals = [1, 2, 3]
doubled = [v * 2 for v in vals] # works — the ITERABLE is evaluated in class scope
tripled = [v * n for v in vals for n in vals] # works
scaled = [v * factor for v in vals] # ⚠️ NameError if factor is a class attribute
The reason: the class body is not a function scope, so it is skipped by the enclosing-scope
lookup — the comprehension's implicit function cannot see it. Only the outermost iterable is
evaluated in the class scope. It is obscure, but it produces a genuinely baffling NameError
in Django/SQLAlchemy model bodies, which is exactly where people write comprehensions over class
attributes.
Part 4 — Where closures are actually useful
# 1. Decorators — the whole mechanism is a closure (Day 044)
def retry(times):
def deco(fn):
def wrapper(*a, **kw):
for attempt in range(times): # `times` captured from the enclosing scope
try: return fn(*a, **kw)
except Exception:
if attempt == times - 1: raise
return wrapper
return deco
# 2. Configured callbacks without a class
def threshold_filter(limit):
return lambda x: x > limit
# 3. Memoisation — the cache lives in the closure, not in a global
def memo(fn):
cache = {} # one cache per decorated function
def wrapper(n):
if n not in cache: cache[n] = fn(n)
return cache[n]
return wrapper
A closure is a poor man's object, and an object is a poor man's closure — both bundle behaviour with state. Use a closure for one function and a little state; use a class when there are several related operations or the state needs a name.
Common mistakes
| Mistake | Correction |
|---|---|
Expecting [lambda: i for i in ...] to capture values |
It captures the variable. Use i=i or a factory. |
| Reading then assigning a global | UnboundLocalError — assignment anywhere makes it local. |
Reaching for global |
Return a value, or hold state on an object. |
Confusing nonlocal and global |
Enclosing function vs module. |
| Shadowing builtins | list = [...] breaks list() far away from the cause. |
| Expecting the loop variable to leak from a comprehension | It has its own scope in Python 3. |
| Comprehension over class attributes | Class scope is not visible inside it. |
| Mutable state in a closure across threads | Cells are shared — the same race as any shared object. |
| Not knowing decorators are closures | It is the same mechanism (Day 044). |
Interview questions
Q: What does this print, and why?
fs = [lambda: i for i in range(3)]
print([f() for f in fs])
[2, 2, 2]. All three lambdas close over the same variable, not over its value at the moment they were created, so when they're finally called the loop has finished andiis 2. Calling them inside the loop would give 0, 1, 2 — the timing is the whole bug. The fixes are a default argument,lambda i=i: i, which binds at definition time, or a factory function so each closure gets its own scope. It matters in real code whenever you register callbacks in a loop, because callbacks are by definition called later.
Q: Explain UnboundLocalError.
If a name is assigned anywhere in a function body, the compiler marks it local for the entire body — that's a compile-time decision, visible in the bytecode as
LOAD_FASTrather thanLOAD_GLOBAL. So reading it before the assignment reads an unbound local, not the global.count += 1triggers it too, since augmented assignment reads before it writes.globalornonlocaldeclares the intent — though wantingglobalusually means the state belongs somewhere else.
Q: global vs nonlocal?
globalbinds to the module namespace and will create the name if it doesn't exist.nonlocalbinds to the nearest enclosing function scope and is a compile-time error if no such binding exists, which makes it much safer — you can't accidentally create state. Neither reaches class scope, which is why comprehensions in a class body can't see class attributes.
Q: What is a closure, mechanically?
A function plus references to variables from an enclosing scope. CPython stores those variables in cell objects on the heap and the inner function holds references to the cells in
__closure__, so the variable outlives the call that created it and the closure observes live updates rather than a snapshot. That's exactly why the loop-variable trap exists — and it's also the machinery behind decorators, which are closures over the function being decorated.
Q: When would you use a closure rather than a class?
When there's one operation and a small amount of configuration or state — a configured predicate, a retry wrapper, a memo cache that belongs to one function. A class earns its place when there are several related operations, when the state needs to be inspected or named, or when it needs to be serialised or subclassed. The saying that a closure is a poor man's object and vice versa is accurate: they're the same idea with different ergonomics.
Mini task
- Write the
[lambda: i for i in range(3)]bug. Print the result. Then fix it three ways. - Call the lambdas inside the loop and show you get
0, 1, 2. Say out loud why. - Reproduce
UnboundLocalError, thendisthe function and findLOAD_FAST. - Write
counter()withnonlocal. Then inspectc.__closure__[0].cell_contentsafter each call. - Remove
nonlocaland read the error. - Shadow
listin a module and calllist()from another function. - Write a comprehension in a class body that references a class attribute. Read the
NameErrorand explain it. - Write a memoising decorator whose cache lives in the closure. Prove two decorated functions have separate caches.
- Register three callbacks in a loop that fire later, hit the bug for real, then fix it.
Exit questions
Answer aloud, no notes.
- What does LEGB stand for, in order?
- What makes a name local, and when is that decided?
- Why does
count += 1causeUnboundLocalErroron a global? globalvsnonlocal— scope reached, and which fails safely?- What is a closure, in terms of cells?
- Why does
[lambda: i for i in range(3)]print[2,2,2]? - Give three fixes and say what each one actually changes.
- Where does this bite in real code? Name three places.
- Does a comprehension leak its variable in Python 3?
- Why can't a comprehension in a class body see class attributes?
- Give three legitimate uses of closures.
- When would you choose a class instead?
Articulation drill
Record two minutes: "Why do all my loop-created callbacks behave identically?"
Diagnose it in one sentence: the closures captured the variable, not its value, and by the time they run the loop has finished. Then show that calling them inside the loop gives the expected answer, which proves the diagnosis rather than asserting it.
Then the fixes, and what each one actually does: lambda i=i: i works because default
arguments are evaluated at definition time — the same mechanism as Day 025's famous bug, used
deliberately this time. A factory function works because each call creates a fresh scope with its
own cell.
Then close with why it is worth knowing rather than looking up: "this isn't a Python quirk — it's how closures work in JavaScript, in Go before 1.22, in C#. It follows from names being bindings rather than boxes, which is the same fact behind aliasing and the mutable default. One model, three famous bugs."
Previous: Day 029 · Tomorrow: Day 031 — the data model: dunder methods as the entire language's interface