Scope, closures & LEGB
When Python sees a name like total, it has to decide which binding you mean. The rule is
short: Local, Enclosing, Global, Built-in (LEGB). And the decision about whether a name
is local is made when the function is compiled, not when it runs. That one fact explains
UnboundLocalError, closures, and the lambda-in-a-loop surprise.
Looking for a colleague
You need "Priya". You check your own desk first (local), then your team's floor (enclosing function), then the company directory (module globals), and finally the public phone book (built-ins). You stop at the first match. If there is a Priya on your floor, the one in the company directory is never reached.
1. The LEGB lookup
if, for and with blocks do not.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print("inner sees:", x)
inner()
print("outer sees:", x)
outer()
print("module sees:", x)
print(len("abc")) # len is found in built-ins
for i in range(3):
pass
print("loop variable after the loop:", i) # for does not create a scope
2. "Local" is decided at compile time
When Python compiles a function, it scans the whole body. Any name that is assigned anywhere in the
function (with =, +=, for x in, import,
def, and so on) becomes local for the entire function, including the lines above the
assignment.
count = 0
def increment():
count += 1 # an assignment, so count is local to the WHOLE function...
return count # ...and reading it before it has a value fails
increment()
count += 1 means count = count + 1, so the compiler marks count as
local. At run time the right-hand side reads the local count, which has no value yet. The
global count is never consulted, even though it exists.
3. global and nonlocal
count = 0
def increment():
global count # "count" in this function means the module-level name
count += 1
increment(); increment()
print(count)
def make_counter():
n = 0
def step():
nonlocal n # "n" means the enclosing function's variable
n += 1
return n
return step
c = make_counter()
print(c(), c(), c())
global
global makes a function depend on hidden module state, which makes it harder to test and
unsafe to call from several threads. It is fine in small scripts. In libraries, pass values in and return
results, or keep state on an object. nonlocal is the respectable cousin: the state lives in a
closure that only its own functions can reach.
4. Closures: functions that remember
A closure is an inner function that uses names from an enclosing function, bundled with those variables so they survive after the outer function returns. Python stores them in cells.
def make_multiplier(factor):
def multiply(x):
return x * factor # factor comes from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10))
print(double.__code__.co_freevars) # names captured from outside
print([cell.cell_contents for cell in double.__closure__])
5. The late-binding loop trap
Closures capture variables, not values. The cell is looked up when the inner function runs, so every function created in a loop sees the variable's final value.
handlers = [lambda: i for i in range(3)]
print([h() for h in handlers]) # all three share one i, which ended at 2
# Fix 1: freeze the value as a default argument (evaluated at definition time)
handlers = [lambda i=i: i for i in range(3)]
print([h() for h in handlers])
# Fix 2: functools.partial binds the value immediately
from functools import partial
def show(i):
return i
handlers = [partial(show, i) for i in range(3)]
print([h() for h in handlers])
Creating button callbacks in a loop, registering event handlers, building a list of tasks or threads
with lambda. If every handler reports the last item, this is the cause.
6. Comprehensions have their own scope
x = "outer"
squares = [x * x for x in range(4)]
print(squares, x) # the comprehension's x did not leak out
class Config:
base = 10
try:
values = [base + n for n in range(3)]
except NameError as e:
error = str(e)
print(Config.error)
The second example is a genuine trap. A class body is a scope, but it is not an enclosing scope
for functions or comprehensions defined inside it, so the comprehension cannot see base.
(The loop source, range(3), is evaluated in the class scope, which is why that part
works.) Inside methods, reach class attributes through self or the class name.
Recap
- LEGB: Local → Enclosing → Global → Built-in. The first match wins.
- Only functions, classes, modules and comprehensions create scopes. Loops and
ifblocks do not. - Assigning a name anywhere in a function makes it local for the whole function, which is the cause of
UnboundLocalError. global/nonlocalredirect assignment to an outer scope.- Closures capture variables in cells, looked up at call time. Freeze loop values with a default argument or
partial.
Checkpoint
UnboundLocalError? total = 5 / def f(): print(total); total = 1
total = 1 makes every total in f refer to the local variable.fs = [lambda: n * 10 for n in range(3)]. What does [f() for f in fs] return?
n, which is 2 when they are called. Use lambda n=n: n * 10 to capture each value.