Skip to main content

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_FAST vs LOAD_GLOBAL vs LOAD_NAME mean in bytecode
  • How global and nonlocal change 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 UnboundLocalError and 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 =, in for 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

globalnonlocal
TargetsModule-level (global) scopeNearest enclosing function scope
Use caseModify module stateModify closure state
Creates new variable?Yes, if not in global scopeNo, 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

ConceptExampleNotes
Local scopedef f(): x = 1x is local to f
Global scopex = 1 at module levelAccessible everywhere
Read globaldef f(): print(x)LEGB finds global x
Modify globaldef f(): global x; x = 2global keyword required
Enclosing scopeInner function reads outer variableNo keyword needed for reading
Modify enclosingnonlocal x; x = new_valnonlocal keyword required
Built-inlen, print, listAlways last in LEGB
UnboundLocalErrorAssign + read before assignCompiler marks as local
Loop variablefor 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 += 1 inside a function makes x local for the entire function - even lines before the assignment
  • global and nonlocal override 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 for loops
  • Class scope is not in LEGB for methods: always use self.attr or ClassName.attr to access class attributes from methods
  • Never shadow built-ins: naming a variable list, len, or print silently breaks the built-in for the entire scope
© 2026 EngineersOfAI. All rights reserved.