Python: Scoping & Closures
LEGB Scoping Rule
Python resolves a name by searching these scopes in order: Local → Enclosing → Global → Built-in.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local" — found in Local scope first
inner()
print(x) # "enclosing"
outer()
print(x) # "global"
Use global x inside a function to write to a module-level global. Use nonlocal x inside a nested function to write to an enclosing (not global) variable — without it, x = ... inside inner() would create a new local variable rather than modifying outer()'s x.
Closures
A closure is a function that remembers variables from its enclosing scope, even after the outer function has already returned and its local frame would normally be gone.
def make_multiplier(n):
def multiplier(x):
return x * n # n is "closed over" — captured from make_multiplier's scope
return multiplier
double = make_multiplier(2)
double(5) # 10 — n=2 is still alive, even though make_multiplier() already returned
This is the mechanism that makes decorators possible (see [[Python Decorators]]) — the inner wrapper function closes over the decorated function and any configuration arguments from the outer factory function.
The Late-Binding Closure Gotcha
Closures capture the variable itself, not its value at the moment the closure was created — a genuinely surprising trap when the closure is created inside a loop.
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — NOT [0, 1, 2]!
Why: all three lambdas share the same i variable (the loop variable, not a fresh copy per iteration) — by the time any lambda is actually called, the loop has finished and i holds its final value, 2. Every lambda looks up i fresh at call time, not at creation time.
# Fix: bind the current value as a default argument, evaluated at DEFINITION time (per lambda)
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2] — correct
Default argument values are evaluated once at definition time (this is the same mechanism behind the mutable-default-argument gotcha in [[Identity, Mutability & Memory]]) — so i=i captures each iteration's current value of i into a fresh default, one per lambda, rather than relying on the shared loop variable at call time.
Why This Matters Beyond Trivia
This exact gotcha shows up in real code whenever callbacks or handlers are constructed in a loop (e.g., binding UI event handlers or building a list of validator functions, each meant to check against a different threshold) — silently getting the same captured value in every closure is a genuine, hard-to-spot production bug class, not just an interview trick question.
Interview Drills
- What does
[lambda: i for i in range(3)][0]()return, and why? —2, not0— all three lambdas share the sameivariable, and by the time any is called, the loop has already finished withi == 2; late binding means the lookup happens at call time, not creation time. - How do you fix a loop-created-closures bug without changing the lambda body's logic? — Add a default argument that captures the current value at each iteration's function-definition time:
lambda i=i: ...— since default values are evaluated once per definition, this freezes each lambda's own copy. - What's the difference between using
globalandnonlocalinside a nested function? —globaltargets a module-level variable regardless of nesting depth;nonlocaltargets the nearest enclosing function's variable (not global) — needed specifically when a nested function wants to modify a variable from its immediately enclosing function, not the module's global scope.
Cross-links
[[Python Decorators]] · [[Identity, Mutability & Memory]]