Python Scope & Name Resolution (LEGB)
A compact, practical guide to how Python resolves names (the LEGB rule), common gotchas, and patterns to write clearer, less‑buggy code.
TL;DR — LEGB in one minute
- L — Local: Names created inside the current function or comprehension.
- E — Enclosing: Names in any outer function scopes (for nested functions/closures).
- G — Global: Names at module (file) level.
- B — Builtins: Names available from the
builtinsmodule (e.g.,len,print).
Resolution order: Python searches L → E → G → B. The first match wins.
Tip: A bare name (no attribute/dot) follows LEGB. Attribute access (e.g.,
obj.x) does not.
Quick Mental Model
- Local = inside the current
def/lambda(and, in Python 3, inside comprehension scopes). - Enclosing = surrounding
defs (nearest outward) used by closures. - Global = top‑level names in the current module.
- Builtins = final fallback from
builtins.
Minimal Examples
1) L, E, G, B in action
x = "global x"
def outer():
x = "enclosing x"
def inner():
x = "local x"
return x
return inner()
print(outer()) # local x
print(x) # global x
If inner doesn’t assign x, it resolves to the enclosing value:
def outer():
x = "enclosing x"
def inner():
return x # resolves to enclosing x
return inner()
2) global — rebind a module‑level name from inside a function
count = 0
def bump():
global count
count += 1
bump(); bump()
print(count) # 2
Without
global,count += 1raisesUnboundLocalErrorbecause assignment markscountas local.
3) nonlocal — rebind a name in an enclosing (non‑global) scope
def counter():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc
c = counter()
print(c(), c(), c()) # 1 2 3
4) Mutating vs. reassigning
items = []
def add(a):
items.append(a) # mutates the object bound to global `items`
def reset():
global items # reassignment needs `global`
items = []
5) Comprehension & generator scope (Python 3)
- List/set/dict comprehensions and generator expressions have their own scope in Python 3. The loop variable does not leak.
x = "global"
vals = [x for x in range(3)]
print(x) # "global"
6) Class scope gotcha
A class body has a namespace during execution, but it is not an enclosing scope for methods. Methods must access class attributes via self or the class name.
x = "module"
class C:
x = "class attr"
def f(self):
return C.x, self.x # qualify explicitly
7) Closures & loop‑variable capture (common pitfall)
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2] (all capture final i)
Fix by binding the current value as a default argument:
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
8) Default‑argument evaluation time
Default parameter values are evaluated once at function definition time (not at call time). Avoid mutable defaults:
def add(item, items=[]): # 🚫 DONT
items.append(item)
return items
Better:
def add(item, items=None):
items = [] if items is None else items
items.append(item)
return items
9) exec, import *, and scope hygiene
exec() and from module import * can obscure where names come from and confuse linters/tools. Avoid in production.
Inspecting Runtime Scopes
locals()→ snapshot of local names.globals()→ module namespace.vars(obj)→ an object’s attribute dict.import builtins→ inspect builtin names.inspect.getclosurevars(func)→ analyze captured names in a closure.
Debugging Playbook
- Reproduce the error (e.g.,
UnboundLocalError). - Probe with
print(locals())or a debugger (pdb,breakpoint()), right before the failing line. - Check assignments: any assignment to a bare name makes it local unless declared
global/nonlocal. - Watch for captures in comprehensions/loops—use the default‑arg trick to freeze values.
- Lint with
ruff/flake8; addmypyfor static checks.
Best Practices & Patterns
- Prefer explicit parameters over reading/writing globals.
- Keep functions small and pure; return values instead of mutating outer state.
- Use
nonlocalsparingly; consider returning new state from inner functions instead. - For closures inside loops, use the default‑argument capture pattern (
lambda i=i: ...). - Avoid mutation of shared global state; if necessary, encapsulate in objects with clear APIs.
- Avoid
from module import *andexec(); prefer explicit imports and names. - Don’t shadow builtins (
list,id,str, …). If you must, alias:from typing import List as TList.
Common Pitfalls — Checklist
-
UnboundLocalErrordue to assignment before use. - Unexpected closure capture in loops/comprehensions.
- Mutable default args causing shared state.
- Assuming class body variables act as an enclosing scope for methods.
- Name shadowing of builtins (
list,id,str, etc.).
Glossary
- Binding: associating a name with an object in a namespace.
- Namespace: a mapping from names to objects (e.g., module
globals()). - Closure: a function that captures variables from its enclosing scope.
Further Reading
- Python docs — Naming and binding: https://docs.python.org/3/reference/executionmodel.html#naming-and-binding
- Scoping of generator/comprehension variables — see PEP 289 history and Python 3 behavior.
- Tools:
inspect,pdb,ruff/flake8,mypy.
Try‑It Snippets (copy/paste)
Probe locals
def demo():
x = 1
print("locals:", locals())
Freeze loop variable
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
assert [f() for f in funcs] == [0, 1, 2]
Safe default pattern
def append_safe(x, bucket=None):
bucket = [] if bucket is None else bucket
bucket.append(x)
return bucket
Advanced Notes & Edge Cases
What counts as a binding?
The following statements bind names in the current local scope (unless marked otherwise):
assignment(including tuple unpacking):x = 1,a, b = b, aaugmented assignment:x += 1(also marksxlocal unlessglobal/nonlocal)def,class: bind the function/class name in the surrounding scopeimport/from ... import ...(bind imported names)except <Exc> as e: bindsein theexceptblock scope onlyforloop target in a function: binds in function local scopewith ... as x: bindsxin the current blockmatch/casepattern variables: bind inside thecaseblock:=walrus operator: binds in the nearest enclosing scope where the expression appears (comprehensions have their own scope in Py3)del name: unbinds (removes) a name from the current scope
global namemeans assignments go to the module scope.
nonlocal namemeans assignments go to the nearest enclosing function scope; the name must already exist there.
global is per‑module
global refers to the current module’s namespace—not all modules.
# mod_a.py
x = 0
def set_via_global():
global x
x = 99
# mod_b.py
import mod_a
mod_a.set_via_global()
print(mod_a.x) # 99
To write another module’s global from here, assign its attribute: mod_a.x = 5.
Shadowing builtins (and how to escape it)
A global or local can shadow a builtin:
len = 42
print(len) # 42
# print(len([1,2])) # TypeError
Escape hatch:
import builtins
print(builtins.len([1,2])) # 2
Prefer not to shadow builtins; use different names.
Comprehensions & := (walrus) in Py3
Comprehensions have their own scope; := binds inside that scope:
x = 'module'
vals = [y := x.upper() for x in 'ab'] # here, `x` is the loop var, local to comp
print('module x =', x) # still 'module'
print(vals, y) # ['A','B'] and NameError (y not bound outside)
match / case pattern variables
Pattern names bind within the case block only:
def describe(t):
match t:
case (x, y): # binds x,y here
return x + y
# x, y not defined here
Classes are mappings, not enclosing scopes
Inside a class body, simple names refer to the class namespace while executing, but methods later look up via attribute access.
class C:
k = 10
lst = []
lst.append(1) # ok: class body executes top‑to‑bottom
def f(self):
return self.k # attribute lookup at runtime
Introspection: freevars & cellvars
def make():
x = 1
def inner():
return x
return inner
f = make()
print(f.__code__.co_freevars) # ('x',)
For deeper analysis:
import inspect
inspect.getclosurevars(f)
Performance tidbits (micro)
Local lookups are faster than globals/builtins. If you’re in a hot loop:
from math import sqrt
s = 0.0
for i in range(10_000):
s += sqrt(i) # local name `sqrt` is a fast lookup
Optimize only when profiling says so.
Exercises (with expected outputs)
- UnboundLocal
n = 10
def f():
n += 1
return n
- Q: What error and why?
- A:
UnboundLocalError— assignment marksnlocal, read happens before assignment.
- Closure capture
funcs = [lambda: i for i in range(4)]
print([f() for f in funcs])
- Expected:
[3, 3, 3, 3] - Fix:
lambda i=i: i→[0,1,2,3]
- Class scope
x = 'module'
class C:
x = 'class'
def show(self):
return x
- Q: What does
C().show()return? - A:
'module'— bare name resolves via LEGB in the method’s scope; useself.xorC.xto access class attr.
- Comprehension leakage
for i in range(3):
pass
print(i)
j = 99
[j for j in range(1)]
print(j)
- Expected (Py3): first
print(i)→2(loop binds in function/module scope). Secondprint(j)→99(comprehension has its own scope;jnot overwritten).
- Walrus in conditions
if (m := 3) > 2:
pass
print(m)
- Expected:
3— bound in the current scope (not a comprehension).
One‑Page Cheat Sheet
LEGB: Local → Enclosing → Global → Builtins
Binders: =, +=, def, class, import, except ... as, for target, with ... as, match/case names, := (scope‑aware), del (unbinds).
Keywords:
global x→ write to module scopenonlocal x→ write to nearest enclosing function scope (must already exist)
Do:
- Pass values as params; avoid globals
- Use default‑arg capture in closures inside loops
- Use
Nonesentinel for optional mutable params - Lint (ruff/flake8) + type‑check (mypy)
Don’t:
- Shadow builtins (
list,id,str, ...) - Use
from module import */execin production - Rely on class body as enclosing scope for methods
Introspect: locals(), globals(), vars(obj), inspect.getclosurevars(f)
FAQ
Q: Why does nonlocal raise SyntaxError: no binding for nonlocal?
A: The name must already exist in a non‑global enclosing function scope.
Q: Why does a comprehension not overwrite my outer loop variable? A: In Python 3, comprehensions run in their own scope; the loop variable doesn’t leak.
Q: How can I modify a module‑level constant safely for tests?
A: Prefer dependency injection (pass it as a parameter) or patch the attribute explicitly in tests (monkeypatch.setattr(mod, 'NAME', value)).