Python Variable Scope: Local, Enclosing, Global, and Built-in (LEGB Rule)
Every name in a Python program exists in a scope — a region of code where that name is accessible. When Python encounters a name like total or calculate, it searches through a chain of scopes in a specific order to find it. Understanding this order prevents confusing bugs and helps you write functions that are predictable and self-contained.
The LEGB Rule
Python searches for names in this order:
- Local — inside the current function
- Enclosing — inside any outer functions (relevant for nested functions)
- Global — at the module level
- Built-in — Python’s built-in names (
len,print,range, etc.)
The first match wins. If no match is found at any level, Python raises a NameError.
Local Scope
Any name assigned inside a function is local to that function. It does not exist before the function is called and is destroyed when the function returns.
def calculate_area(radius): pi = 3.14159 # local variable area = pi * radius ** 2 # also local return area
print(calculate_area(5)) # 78.53975# print(pi) # NameError — pi does not exist outside the functionLocal scope provides isolation: two functions can both have a variable named result without conflicting with each other.
Global Scope
Names assigned at the module level (outside any function or class) are in the global scope. Any function in that module can read them.
MAX_RETRIES = 3 # global constant
def attempt_connection(host): for i in range(MAX_RETRIES): # reads global without issue print(f"Attempt {i + 1} connecting to {host}") # ...Reading a global variable is straightforward. Modifying it inside a function requires declaring global first. Without it, Python treats any assignment inside the function as creating a new local variable:
request_count = 0 # global
def handle_request(): global request_count # declares intent to modify the global request_count += 1
handle_request()handle_request()print(request_count) # 2Without global request_count, the line request_count += 1 raises UnboundLocalError because Python sees the assignment and marks request_count as a local variable for the entire function — but it has no local value to read from before the += 1.
Why Global State Is a Design Smell
Using mutable global variables creates hidden dependencies between functions. Any function anywhere in the module can change the global, making it hard to reason about the state at any given point.
# Fragile — side effects through global statecounter = 0
def increment(): global counter counter += 1
def reset(): global counter counter = 0
# Testing increment() requires knowing the value of counter firstA better approach passes state explicitly:
# Better — functions work on the data they receivedef increment(counter): return counter + 1
def reset(): return 0
count = 0count = increment(count)count = increment(count)print(count) # 2Global constants (configuration, mathematical constants) are fine. Mutable global variables that functions modify are a sign the code should be restructured — perhaps into a class.
Enclosing Scope and Closures
When functions are nested, the inner function can access names from the outer function’s scope. This is the enclosing scope, and the combination of the inner function plus the enclosed variables is called a closure.
def make_multiplier(factor): def multiply(number): return number * factor # factor comes from enclosing scope return multiply
double = make_multiplier(2)triple = make_multiplier(3)
print(double(5)) # 10print(triple(5)) # 15print(double(10)) # 20multiply references factor from make_multiplier’s scope. Each call to make_multiplier creates a new enclosing scope with its own factor, so double and triple maintain separate state.
Closures are how Python implements many functional programming patterns, decorators, and factory functions.
nonlocal: Modifying an Enclosing Variable
Just as global allows modification of a module-level variable, nonlocal allows an inner function to modify a variable in its enclosing function’s scope.
def make_counter(): count = 0
def increment(): nonlocal count # modify the enclosing variable count += 1 return count
def reset(): nonlocal count count = 0
return increment, reset
inc, rst = make_counter()print(inc()) # 1print(inc()) # 2print(inc()) # 3rst()print(inc()) # 1Without nonlocal, each count += 1 inside increment would create a local count variable and raise UnboundLocalError.
Built-in Scope
The built-in scope contains Python’s built-in names: print, len, range, list, dict, type, isinstance, and hundreds more. It is always the last place Python looks.
You can shadow built-ins by creating a local or global variable with the same name — which is usually a mistake:
# Accidental shadowing — now "list" refers to your variable, not the built-inlist = [1, 2, 3]print(list) # [1, 2, 3]list([4, 5, 6]) # TypeError — list is now a variable, not a function
# Fix: delete or renamedel listprint(list([4, 5, 6])) # [4, 5, 6] — built-in is accessible againAvoid naming local or global variables the same as built-in names.
Practical Example: Scope in Context
DISCOUNT_RATE = 0.1 # global constant
def calculate_order(items): subtotal = sum(price for _, price in items) # local discount = subtotal * DISCOUNT_RATE # reads global total = subtotal - discount # local return total
order = [("Widget", 29.99), ("Gadget", 49.99), ("Doodad", 9.99)]print(f"Total: £{calculate_order(order):.2f}")# Total: £80.97DISCOUNT_RATE is a module-level constant — reading it from a function is appropriate. subtotal, discount, and total are local — they exist only for the duration of calculate_order.
Summary
| Scope | Where | Read? | Modify without keyword? |
|---|---|---|---|
| Local | Inside current function | Yes | Yes |
| Enclosing | Outer function (closures) | Yes | nonlocal needed |
| Global | Module level | Yes | global needed |
| Built-in | Python internals | Yes | Cannot (shadow only) |
The general advice: keep variables as local as possible. Pass data in as parameters and return data out as return values. Reserve global and nonlocal for cases where they genuinely simplify the design, not as a way to avoid thinking about function interfaces.