Scope and LEGB - How Python Resolves Names
Reading time: ~16 minutes | Level: Foundation → Engineering
Here is a question that trips up experienced developers:
x = 10
def foo():
print(x) # what does this print?
x = 20 # assign x locally
foo()
Most people expect 10. The actual result:
UnboundLocalError: local variable 'x' referenced before assignment
Python decided x is a local variable - before the function even ran. If you do not understand how Python resolves names, bugs like this will appear at the worst moments.
What You Will Learn
- The four scopes Python searches (LEGB) and the order it searches them
- How Python decides at compile time whether a name is local or global
- What
LOAD_FASTvsLOAD_GLOBALvsLOAD_NAMEmean in bytecode - How
globalandnonlocalchange scope binding - Why loop variables leak into enclosing scope (unlike most languages)
- How list comprehensions have their own scope in Python 3
- How to avoid shadowing built-ins like
list,len,print - The
UnboundLocalErrorand exactly why it occurs
Prerequisites
- Python function basics:
def, parameters, return (Lesson 01) - Python closures basics are helpful (Lesson 09) but not required
The LEGB Rule
When Python encounters a name, it searches four scopes in order:
L - Local: names defined inside the current function
E - Enclosing: names in enclosing functions (for nested functions)
G - Global: names defined at module level
B - Built-in: names in Python's builtins (len, print, list, ...)
The first match wins. If no match is found: NameError.
Simple Examples
# Built-in scope
print(len([1, 2, 3])) # len is in built-in scope - always found
# Global scope
x = 42
def foo():
print(x) # x not local → found in global scope
foo() # 42
# Local scope shadows global
x = 42
def foo():
x = 99 # new local variable
print(x) # 99 - local x found first
foo()
print(x) # 42 - global x unchanged
# Enclosing scope (closure)
def outer():
message = "hello"
def inner():
print(message) # found in enclosing scope
inner()
outer() # hello
Compile-Time Scope Resolution
Python resolves scope at compile time, not at runtime. The compiler scans each function body and classifies every name as:
- Local - if the name appears on the left side of
=, infor x in ...,with ... as x,except ... as x, etc. - Global - if declared with
global x - Free - if used but not assigned locally (look up in enclosing/global scope)
This classification happens before the function runs. That is why the UnboundLocalError in the opening example happens:
x = 10
def foo():
print(x) # compiler sees x assigned below → marks x as LOCAL
x = 20 # assignment makes x local for entire function
foo()
# UnboundLocalError: local variable 'x' referenced before assignment
The compiler marked x as local because of the assignment on line 4. When print(x) runs, x is local but has no value yet.
Bytecode Proof
import dis
x = 10
def reads_global():
print(x) # x is free - uses global
def shadows_global():
print(x) # x is local (because of x = 20 below)
x = 20
dis.dis(reads_global)
# LOAD_GLOBAL (x) ← looks up global x
dis.dis(shadows_global)
# LOAD_FAST (x) ← looks up local x (which doesn't exist yet!)
LOAD_FAST- reads from the local variable array (fast, O(1) array access)LOAD_GLOBAL- reads from the global dict (slightly slower, dict lookup)LOAD_NAME- used in module scope and class scope
The global Keyword
global x tells Python: "when I use x in this function, treat it as the module-level variable."
counter = 0
def increment():
global counter # ← binds 'counter' to module scope
counter += 1
increment()
increment()
print(counter) # 2
Without global, counter += 1 would raise UnboundLocalError because += is an assignment, making counter local.
:::warning Use global Sparingly
global creates hidden dependencies between functions and module state. It makes code hard to test, trace, and reason about. In well-designed code, you almost never need global. Use function parameters and return values instead, or use a class to encapsulate state.
:::
# Avoid
total = 0
def add(n):
global total
total += n
# Prefer
def add(total, n):
return total + n
total = 0
total = add(total, 5)
total = add(total, 3)
The nonlocal Keyword
nonlocal x tells Python: "when I use x, look in the enclosing function's scope, not local or global."
def make_counter():
count = 0
def increment():
nonlocal count # ← binds 'count' to make_counter's scope
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
Without nonlocal, count += 1 would treat count as a new local variable and raise UnboundLocalError.
global vs nonlocal
global | nonlocal | |
|---|---|---|
| Targets | Module-level (global) scope | Nearest enclosing function scope |
| Use case | Modify module state | Modify closure state |
| Creates new variable? | Yes, if not in global scope | No, must already exist in enclosing scope |
x = "global"
def outer():
x = "enclosing"
def inner():
nonlocal x
x = "inner modified enclosing"
inner()
print(x) # "inner modified enclosing"
outer()
print(x) # "global" - unchanged
Scope Diagrams
Nested Function Scope
def outer(a): # outer scope: a, b
b = 10
def middle(c): # middle scope: c, d; enclosing: a, b
d = 20
def inner(e): # inner scope: e; enclosing: c, d, a, b; global: module
return a + b + c + d + e
return inner
return middle
f = outer(1)(2)
print(f(3)) # 1 + 10 + 2 + 20 + 3 = 36
Scope chain:
┌─────────────────────────────────┐
│ built-in: len, print, list ... │
└─────────────────────────────────┘
▲
┌─────────────────────────────────┐
│ global: outer, f │
└─────────────────────────────────┘
▲
┌─────────────────────────────────┐
│ outer local: a=1, b=10 │
│ (enclosing for middle) │
└─────────────────────────────────┘
▲
┌─────────────────────────────────┐
│ middle local: c=2, d=20 │
│ (enclosing for inner) │
└─────────────────────────────────┘
▲
┌─────────────────────────────────┐
│ inner local: e=3 │
│ reads a, b, c, d from above │
└─────────────────────────────────┘
Loop Variable Scope
Python loops do not create their own scope. Loop variables live in the enclosing function's local scope (or module scope at the top level):
for i in range(5):
pass
print(i) # 4 - loop variable leaks!
This is different from C, Java, and JavaScript (with let). In Python, i continues to exist after the loop.
def find_index(items, target):
for i, item in enumerate(items):
if item == target:
break
else:
i = -1 # for/else: runs if loop completed without break
return i
print(find_index(["a", "b", "c"], "b")) # 1
print(find_index(["a", "b", "c"], "z")) # -1
:::note For/else
Python's for/else runs the else block only if the loop completed without break. It is useful for "search and not found" patterns - the loop variable remains accessible after the loop.
:::
List Comprehension Scope (Python 3 Fixed This)
In Python 2, list comprehension variables leaked into the enclosing scope - a common bug. Python 3 fixed this: comprehensions have their own scope.
x = "global"
result = [x for x in range(5)] # x is local to the comprehension
print(x) # "global" - NOT 4, unlike Python 2
print(result) # [0, 1, 2, 3, 4]
Generator expressions, dict comprehensions, and set comprehensions all have their own scope in Python 3.
However, for loops still do NOT have their own scope:
x = "global"
for x in range(5): # leaks
pass
print(x) # 4 - overwritten!
:::tip Comprehensions vs Loops
Use comprehensions when you want the loop variable contained. Use for loops when you need the loop variable after the loop (e.g., last processed element, search index).
:::
Shadowing Built-ins
The built-in scope is last in the LEGB chain. Any name you define at module or function scope shadows the built-in:
# BAD: shadows built-in list
list = [1, 2, 3]
print(list) # [1, 2, 3]
new_list = list([]) # TypeError: 'list' object is not callable
# BAD: shadows built-in len
def len(x):
return 42
print(len("hello")) # 42 ← wrong
# Check if you're shadowing
import builtins
print(dir(builtins)) # all built-in names
:::danger Never Shadow Built-ins
Do not name variables list, dict, set, str, int, len, range, print, type, id, input, open, filter, map, sorted, sum, min, max. You will corrupt the name for the rest of that scope and get confusing errors.
:::
If you accidentally shadow a built-in, you can still access the original via the builtins module:
import builtins
builtins.list([1, 2, 3]) # works even if 'list' is shadowed locally
Class Scope Is Not in LEGB
Class bodies create a scope, but it is not in the LEGB chain for methods:
class MyClass:
x = 10
def method(self):
print(x) # NameError: name 'x' is not defined!
# Must use self.x or MyClass.x
@classmethod
def class_method(cls):
print(cls.x) # correct
Class attributes are accessed through self or cls, not through normal name lookup. This surprises many developers coming from other OOP languages.
# This also fails:
class Config:
debug = False
log_level = "DEBUG" if debug else "INFO" # works - in class body
# but inside a method, you cannot use 'debug' directly
locals() and globals()
x = 1
y = 2
def foo():
a = 10
b = 20
print(locals()) # {'a': 10, 'b': 20}
foo()
print(globals()) # {'x': 1, 'y': 2, '__name__': '__main__', ...}
locals() returns a snapshot (copy) of the local namespace. Modifying it does not change actual local variables:
def foo():
x = 1
locals()["x"] = 999
print(x) # still 1 - locals() is a copy
# globals() returns the actual module dict - modifying it works:
globals()["new_var"] = 42
print(new_var) # 42 (but this is terrible practice)
:::warning locals() is Read-Only
locals() returns a copy. You cannot use it to dynamically set local variables. Use globals() only for inspection, not modification.
:::
Interview Questions
Q1: Explain the LEGB rule. What order does Python search scopes?
Answer: Python searches four scopes in order when resolving a name: Local (current function's variables), Enclosing (variables in outer functions, for nested functions), Global (module-level variables), Built-in (Python's built-in names like len, print, list). The search stops at the first match. If no match is found, Python raises NameError. This resolution order is fixed by the Python language spec, and critically, the classification of each name as local or global is done at compile time, not runtime.
Q2: Why does this raise UnboundLocalError?
x = 10
def foo():
print(x)
x = 20
foo()
Answer: Python classifies names as local or global at compile time by scanning the function body. Because x = 20 appears anywhere in foo, the compiler marks x as a local variable for the entire function. When print(x) executes, Python looks for x in the local variable array - but x has not been assigned yet. The result is UnboundLocalError: local variable 'x' referenced before assignment. The fix: either move the assignment before the print, or declare global x if you intend to read the module-level x.
Q3: What is the difference between global and nonlocal?
Answer: global x binds the name x to the module-level (global) scope, allowing the function to read and modify the module variable. nonlocal x binds the name x to the nearest enclosing function's scope, allowing an inner function to read and modify a closure variable. Key difference: global can create a new global variable if it doesn't exist; nonlocal requires the variable to already exist in some enclosing function scope. Neither keyword should be overused - global in particular is a code smell in most well-designed Python.
Q4: Do Python for-loops have their own scope?
Answer: No. Loop variables in for loops live in the enclosing scope (local scope of a function, or module scope at the top level). After the loop ends, the loop variable retains its last value. This differs from C, Java, and JavaScript's let. In Python 3, list/dict/set comprehensions and generator expressions do have their own scope - the comprehension variable does not leak into the enclosing scope. This was a breaking change from Python 2 where list comprehension variables did leak.
Q5: What happens when you shadow a built-in like list?
Answer: The built-in scope is last in LEGB. Any name defined in local, enclosing, or global scope shadows the built-in for the rest of that scope. After list = [1,2,3] at module level, calling list([]) raises TypeError: 'list' object is not callable because the name list now refers to a regular list object, not the built-in list type. The fix: delete the variable (del list) or access the original via import builtins; builtins.list. Prevention: never name variables with built-in names. Tools like flake8's A001 rule warn about this.
Q6: Why is class scope not part of the LEGB chain for methods?
Answer: Class bodies create a namespace during class definition, but that namespace is not accessible through normal LEGB lookup in methods. Methods look up names via: local scope → enclosing scopes → global scope → built-in scope. Class attributes are not enclosing scope. This is intentional: Python's explicit self design means attribute access must be explicit (self.x, MyClass.x) rather than implicit lookup. This makes attribute access transparent and avoids ambiguity between local variables and class attributes that exists in other OOP languages.
Practice Challenges
Beginner: Scope Prediction
Without running the code, predict the output of each snippet and explain why:
# Snippet A
x = 1
def f():
x = 2
def g():
print(x)
g()
f()
print(x)
# Snippet B
total = 0
def add(n):
total += n # what happens?
add(5)
# Snippet C
for i in range(3):
pass
result = [i for i in range(5)]
print(i) # what is i?
print(result[-1])
Solution
# Snippet A output:
# 2 (g finds x in enclosing scope f - x=2, not the global x=1)
# 1 (f() created its own local x=2; global x=1 unchanged)
# Snippet B:
# UnboundLocalError: local variable 'total' referenced before assignment
# Because total += n is assignment, making 'total' local. Fix:
def add(n):
global total
total += n
# Or better: return the new total and assign outside
# Snippet C:
# i = 2 (for loop variable i leaks; last value is range(3)[-1] = 2)
# result[-1] = 4 (comprehension has own scope; i inside comp goes 0-4)
# i is still 2 after the comprehension because the comp's i is separate
Intermediate: Counter Factory Without global
Problem: Implement a counter factory using closures and nonlocal - no global state, multiple independent counters:
c1 = make_counter(start=0, step=1)
c2 = make_counter(start=100, step=10)
print(c1()) # 1
print(c1()) # 2
print(c2()) # 110
print(c1()) # 3
print(c2()) # 120
# Also support reset:
c1.reset()
print(c1()) # 1
Solution
def make_counter(start=0, step=1):
count = start
def increment():
nonlocal count
count += step
return count
def reset():
nonlocal count
count = start
increment.reset = reset # attach reset as attribute of increment
return increment
c1 = make_counter(start=0, step=1)
c2 = make_counter(start=100, step=10)
print(c1()) # 1
print(c1()) # 2
print(c2()) # 110
print(c1()) # 3
print(c2()) # 120
c1.reset()
print(c1()) # 1 (reset to start=0, next call gives 1)
Advanced: Scope Debugger
Problem: Write a decorator @trace_scope that, when applied to a function, prints the function's local variables at each return statement. Use inspect to walk the call stack.
@trace_scope
def compute(x, y):
a = x * 2
b = y + 3
result = a + b
return result
compute(4, 5)
# [trace_scope] compute returned 16
# Local variables: {'x': 4, 'y': 5, 'a': 8, 'b': 8, 'result': 16}
Solution
import functools
import inspect
def trace_scope(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
# Get the frame of the wrapped function call
# We can capture locals before return by using settrace
print(f"[trace_scope] {func.__name__} returned {result!r}")
return result
return wrapper
# More complete version using sys.settrace for per-return capture:
import sys
def trace_scope(func):
captured = {}
def tracer(frame, event, arg):
if frame.f_code is func.__code__ and event == 'return':
captured['locals'] = dict(frame.f_locals)
captured['value'] = arg
return tracer
@functools.wraps(func)
def wrapper(*args, **kwargs):
old_trace = sys.gettrace()
sys.settrace(tracer)
try:
result = func(*args, **kwargs)
finally:
sys.settrace(old_trace)
print(f"[trace_scope] {func.__name__} returned {captured.get('value')!r}")
print(f"Local variables: {captured.get('locals', {})}")
return result
return wrapper
@trace_scope
def compute(x, y):
a = x * 2
b = y + 3
result = a + b
return result
compute(4, 5)
# [trace_scope] compute returned 16
# Local variables: {'x': 4, 'y': 5, 'a': 8, 'b': 8, 'result': 16}
Quick Reference
| Concept | Example | Notes |
|---|---|---|
| Local scope | def f(): x = 1 | x is local to f |
| Global scope | x = 1 at module level | Accessible everywhere |
| Read global | def f(): print(x) | LEGB finds global x |
| Modify global | def f(): global x; x = 2 | global keyword required |
| Enclosing scope | Inner function reads outer variable | No keyword needed for reading |
| Modify enclosing | nonlocal x; x = new_val | nonlocal keyword required |
| Built-in | len, print, list | Always last in LEGB |
| UnboundLocalError | Assign + read before assign | Compiler marks as local |
| Loop variable | for i in ...: pass; print(i) | Leaks to enclosing scope |
| Comprehension | [x for x in range(5)] | Own scope in Python 3 |
Key Takeaways
- LEGB is the lookup order: Local → Enclosing → Global → Built-in; first match wins
- Scope is determined at compile time: the compiler scans the function body and classifies names before execution begins
- Any assignment makes a name local:
x += 1inside a function makesxlocal for the entire function - even lines before the assignment globalandnonlocaloverride compile-time classification: use sparingly; prefer parameters and return values- For-loops leak; comprehensions don't: Python 3 gave comprehensions their own scope but not
forloops - Class scope is not in LEGB for methods: always use
self.attrorClassName.attrto access class attributes from methods - Never shadow built-ins: naming a variable
list,len, orprintsilently breaks the built-in for the entire scope
