Variable Scope & the LEGB Rule
Navigate Local, Enclosing, Global, and Built-in namespaces, closures, and the nonlocal keyword.
The Concentric Glass Domes Mental Model
Think of variable scope as a series of concentric glass domes. When Python looks for a variable name, it starts inside your innermost local dome (the currently running function). If it finds the name there, it uses it immediately. If not, it looks out into any enclosing domes (nested functions), then through the module-level global dome, and finally out into the planetary built-in universe. You can always peer outward through the glass to read variables from outer scopes, but you cannot reach through to change what an outer label points to without an explicit permit: the global keyword for module level, or the nonlocal keyword for enclosing functions. Recursion builds a tower of identical local domes on top of one another—each with its own private copies of variables—until the top dome reaches the base case and sends results rippling back down through the stack.
02 / Scope Directives & Syntax Anatomy
Python looks outward automatically to read variables, but modifying or reassigning an outer variable requires explicit declaration keywords.
total_requests = 0 # Global scope
def handle_request():
global total_requests
total_requests += 1 # Reassigns module variableInstructs the compiler that assignments to total_requests target the module's global __dict__, not a new local variable.
def make_counter():
count = 0 # Enclosing scope
def increment():
nonlocal count
count += 1
return count
return incrementBinds the variable to the nearest enclosing non-global function scope using a heap-allocated closure cell.
03 / CPython Virtual Machine Internals & Frame Stacks
Under the hood, CPython resolves scopes using statically compiled symbol tables, specialized bytecode instructions, and heap-allocated cell objects:
Local function variables bypass hash lookups. CPython compiler assigns them to indexed slots in the frame's fastlocals C array, accessed in single nanoseconds via LOAD_FAST.
Enclosing variables survive outer function termination because CPython allocates a PyCellObject on the heap. Both outer and inner functions hold pointers to this cell via LOAD_DEREF.
If an assignment (x = 10) appears anywhere in a function body, CPython statically flags x as local for the entire function. Reading it before that line raises UnboundLocalError.
Python deliberately omits Tail Call Optimization (TCO) to preserve pristine stack traces. Each call pushes a new _PyInterpreterFrame, guarded by sys.getrecursionlimit() (default 1000).
04 / Scope Layer Comparison Matrix
| Layer / Keyword | Search Order | Storage Mechanism | Rebinding Syntax | CPython Bytecode |
|---|---|---|---|---|
| Local (L) | 1st | Frame fastlocals array | x = val | LOAD_FAST |
| Enclosing (E) | 2nd | Heap PyCellObject | nonlocal x | LOAD_DEREF |
| Global (G) | 3rd | Module __dict__ hash table | global x | LOAD_GLOBAL |
| Built-in (B) | 4th | builtins module dict | Read-only | LOAD_GLOBAL (fallback) |
05 / Official Syntax Reference
Exists only during function execution frame; stored in fastlocals.
def f(): x = 1Stored in heap cell object; binds to nearest enclosing function scope.
def outer(): x=0; def inner(): nonlocal x; x+=1Module-level namespace dictionary; persists as long as module is loaded.
global counter; counter += 1Built-in functions and types; universally available across all modules.
len([1, 2, 3])Halts call stack expansion; provides terminal calculation result.
if n == 0: return 006 / Tiered Code Walkthroughs & Worked Examples
The LEGB Lookup Hierarchy in Action
Demonstrating how Python resolves variables through Local, Enclosing, Global, and Built-in scopes.
layer = 'GLOBAL'
def outer():
layer = 'ENCLOSING'
def inner():
layer = 'LOCAL'
return layer
return inner()
print('Inner resolution (Local wins):', outer())
def outer_fallback():
layer = 'ENCLOSING'
def inner():
# No local 'layer', resolves from enclosing
return layer
return inner()
print('Fallback to enclosing:', outer_fallback())
print('Module level global unchanged:', layer)
Name Shadowing vs. Modifying Globals with the global Keyword
Showing how local variable assignment shadows globals by default, and how the global keyword permits reassigning module state.
app_status = 'STARTING'
request_count = 0
def shadow_status():
# Shadowing: creates a new local variable, leaves global untouched
app_status = 'RUNNING'
print('Inside shadow_status():', app_status)
shadow_status()
print('Global app_status after shadow_status():', app_status)
def update_global_status():
global app_status, request_count
app_status = 'ONLINE'
request_count += 1
update_global_status()
print('Global app_status after update_global_status():', app_status)
print('Global request_count:', request_count)
Stateful Closures Using the nonlocal Keyword
Retaining and mutating state across function calls using enclosing lexical scope and nonlocal.
def make_rate_limiter(max_requests):
"""Factory function generating a stateful request throttler."""
current_requests = 0
def record_request():
nonlocal current_requests
if current_requests >= max_requests:
return False, 'Rate limit exceeded'
current_requests += 1
return True, f'Request accepted ({current_requests}/{max_requests})'
return record_request
limiter = make_rate_limiter(2)
print(limiter())
print(limiter())
print(limiter()) # Rejected
Inspecting Scopes with globals() and locals()
Examining the active namespace dictionaries at module level and inside execution frames.
session_id = 'sess_xyz_100'
def inspect_environment(param_a, param_b='default'):
local_var = 42
current_locals = locals()
print('Local variable keys:', sorted(current_locals.keys()))
print('Param a value:', current_locals['param_a'])
inspect_environment('custom_input')
# globals() returns the live module dictionary
module_globals = globals()
print('session_id in globals?', 'session_id' in module_globals)
Anatomy of Recursion: Factorial Calculation
Understanding the base case stopping condition and recursive step unwinding.
def factorial(n):
# 1. Base Case: 0! and 1! are explicitly 1
if n <= 1:
print(f' Base case reached at n={n}: returning 1')
return 1
# 2. Recursive Step: n * factorial(n - 1)
print(f' Recursing: {n} * factorial({n - 1})')
sub_result = factorial(n - 1)
total = n * sub_result
print(f' Unwinding: {n} * {sub_result} = {total}')
return total
print('Calculating factorial(4):')
res = factorial(4)
print('Final Result:', res)
Deep Tree Traversal via Recursion
Summing values in arbitrarily nested data structures using recursive case dispatch.
def sum_nested(items):
total = 0
for item in items:
if isinstance(item, list):
# Recursive case: item is a sub-list
total += sum_nested(item)
else:
# Base case: item is a scalar number
total += item
return total
nested_data = [1, [2, [3, 4], 5], 6, [7, [8, [9]]]]
print('Sum of deeply nested list:', sum_nested(nested_data))
The Recursion Limit and RecursionError Safeguard
Inspecting sys.getrecursionlimit() and catching RecursionError when a base case is omitted.
import sys
print('Default Python recursion limit:', sys.getrecursionlimit())
def runaway(count):
# Bug: missing base case!
return runaway(count + 1)
try:
runaway(1)
except RecursionError as err:
print('Caught safeguard error:', err)
07 / Interactive LEGB Scope Resolution Simulator
Toggle which scopes contain variable x or apply scope keywords. Observe CPython's exact name resolution priority and bytecode generation.
Simulates reading x before its assignment inside the same function body.
Resolved in Local frame fastlocals array via LOAD_FAST in nanosecond O(1) time.
08 / Interactive Recursion & Call Stack Unwinding Lab
Step through a recursive factorial(n) invocation. Watch the call stack push independent interpreter frames and then unwind as results return.
09 / Common Anti-Patterns & Pitfalls
If a variable is assigned to anywhere in a function body, Python flags it as local for the ENTIRE function. Referencing that variable before the assignment executes causes UnboundLocalError: cannot access local variable 'x' where it is not associated with a value, even if a global variable of the exact same name exists.
count = 10
def increment():
# Python sees 'count = ...' below, so count is marked LOCAL!
# Reading count here crashes with UnboundLocalError
try:
count = count + 1
except UnboundLocalError as e:
print('UnboundLocalError caught:', e)
increment()
count = 10
def increment():
global count # Explicitly declares intent to modify module global
count = count + 1
increment()
print('Count successfully incremented:', count)
Relying on global variables to pass data in and out of functions tightly couples components, introduces hidden side-effects, breaks thread safety, and makes unit testing difficult. Prefer passing parameters and returning values.
# Bad: hidden global mutations
result = None
def calculate_tax(amount):
global result
result = amount * 0.08
# Good: pure function with inputs and output
def calculate_tax(amount):
return amount * 0.08
tax = calculate_tax(100)
Inside a function, locals() returns a dictionary copy/snapshot of the fastlocals array. Modifying the dictionary returned by locals() has zero effect on the actual local variables.
def attempt_local_mutation():
x = 10
locals()['x'] = 99 # Does NOT update local x!
print('x value:', x) # Still 10!
attempt_local_mutation()
def direct_local_assignment():
x = 10
x = 99 # Directly assign to update
print('x value:', x)
direct_local_assignment()
If a recursive function lacks a base case, or if recursive inputs fail to progress toward the base case (e.g. calling f(n) instead of f(n - 1)), the call stack will expand until memory limits are hit, raising RecursionError.
# Bug: calling countdown(n) without decrementing input
def countdown(n):
if n == 0: return
# countdown(n) -> Infinite loop!
def countdown(n):
if n <= 0:
return
countdown(n - 1) # Strictly progresses toward base case
10 / Compiler Edge Cases & Quirks
In Python, calling a mutating method on a global object (such as list.append() or dict[key]=val) does NOT reassign the variable name. Therefore, it does NOT require the global keyword! Only rebinding the name (using = or +=) requires global.
cache = []
def add_to_cache(item):
# No 'global cache' needed because cache is NOT reassigned
cache.append(item)
add_to_cache('entry_1')
print('Global cache mutated:', cache)
When functions are created inside a loop and reference the loop variable, Python closures bind to the variable name, not its value at creation time. All functions will see the final value of the loop variable unless an argument default (i=i) is used to capture it eagerly.
# Flawed: all functions see final loop value 2
flawed_funcs = [lambda: i for i in range(3)]
print('Flawed closures:', [f() for f in flawed_funcs])
# Fixed: capture eager value via parameter default
fixed_funcs = [lambda i=i: i for i in range(3)]
print('Fixed closures:', [f() for f in fixed_funcs])
Declaring nonlocal x raises a SyntaxError: no binding for nonlocal 'x' found if no enclosing function scope defines x. nonlocal will never bind to a global variable.
x = 'global_var'
def test_nonlocal():
# SyntaxError: no binding for nonlocal 'x' found
# nonlocal x
pass
print('nonlocal cannot target global scope.')
Even when a recursive call is in tail position (return f(n - 1)), Python retains the stack frame until completion. Deep recursion always risks stack overflow.
import sys
print('Recursion limit:', sys.getrecursionlimit())
11 / Execution Performance & Memory Tradeoffs
Iterative loops (while, for) reuse a single stack frame with zero allocation overhead. Recursive calls allocate a new _PyInterpreterFrame for every level, consuming stack memory and execution time.
LOAD_FAST (local scope) is nearly 2x faster than LOAD_GLOBAL in bytecode execution because it accesses a C array offset directly rather than hashing a dictionary.
Using nonlocal and closures places variables inside PyCellObject heap allocations rather than direct stack slots, slightly increasing dereferencing time.
sys.setrecursionlimit(N) can increase the recursion ceiling, but setting it too high can trigger an unrecoverable C segmentation fault when the operating system stack overflows.
12 / Engineering Workflow: Choosing Scope Patterns
Always pass parameters in and return values out. Avoid mutating module globals or using global unless managing singleton lifecycle state.
Use nonlocal inside nested function factories (e.g. rate limiters, caching decorators) where persistent private state is required without building a class.
Python has no TCO. For tree traversals with unbounded depth (> 1000 items), convert recursion into an iterative loop with an explicit list stack.
13 / Real-World Production Architecture
Lexical Closures for Factory Functions & Memoization
Modern Python frameworks use enclosing scopes (nonlocal) to create stateful function factories, such as rate limiters, rolling average calculators, and caching decorators without defining custom classes.
Recursive File System & JSON Document Walkers
Directory search utilities and JSON schema validators use recursion to navigate arbitrarily nested folder hierarchies and object trees cleanly.
Parsing and Abstract Syntax Tree (AST) Compilers
Compilers and query parsers use recursive descent algorithms to evaluate mathematical expressions and syntax trees matching nested grammar structures.
14 / Instant Self-Check Quiz Booster
message = 'outside'
def show():
message = 'inside'
print(message)
show()
print(message)score = 0
def add_point():
global score
score = score + 1
add_point()
print(score)def count(number):
if number == 0:
return 0
return 1 + count(number - 1)
print(count(3))color = 'blue'
def paint(color):
print(color)
paint('red')
print(color)def factorial(number):
if number == 0:
return 1
return number * factorial(number - 1)
print(factorial(4))15 / Guided Practice Labs & Test Suites
Summing Arbitrarily Nested Lists Recursively
Write a recursive function deep_sum(nested_list) that calculates the sum of all numbers in a nested list of arbitrary depth. If nested_list is empty, return 0.
def deep_sum(nested_list):
# Sum all integers in arbitrarily nested list
pass
assert deep_sum([1, [2, [3, 4], 5], 6]) == 21 assert deep_sum([]) == 0 assert deep_sum([[10], [20, [30]]]) == 60
Stateful Accumulator Factory with nonlocal
Write a factory function make_accumulator(initial=0) that returns a function add(val). Each call to add(val) should increment the internal running sum and return the updated total.
def make_accumulator(initial=0):
# Return accumulator function maintaining state via nonlocal
pass
acc = make_accumulator(10) assert acc(5) == 15 assert acc(10) == 25 acc2 = make_accumulator(0) assert acc2(1) == 1 assert acc(0) == 25 # acc and acc2 are independent
Recursive String Inversion
Write a recursive function reverse_string(s) that reverses a string without using slicing ([::-1]) or the built-in reversed() function.
def reverse_string(s):
# Reverse string recursively
pass
assert reverse_string('hello') == 'olleh'
assert reverse_string('') == ''
assert reverse_string('a') == 'a'
assert reverse_string('python') == 'nohtyp'
16 / Production Debugging Lab
UnboundLocalError: cannot access local variable 'total' where it is not associated with a value.A tree processing script crashed with a RecursionError during deep traversal.17 / Key Takeaways & Executive Summary
- Python resolves variable names following the LEGB order: Local, Enclosing, Global, Built-in.
- Any assignment to a variable inside a function marks it as local, which can trigger UnboundLocalError if read before assignment.
- Use global to reassign module-level variables; use nonlocal to rebind variables in an enclosing function.
- Recursion is a function calling itself; every valid recursive function requires a base case and a recursive step.
- CPython limits recursion depth (default 1000) and does not optimize tail calls.
Worked example
Count down exam days with recursion — the function calls itself on a smaller problem until it hits the base case.
def countdown(days):
if days == 0:
return "Exam day!"
print(days)
return countdown(days - 1)
print(countdown(3))
How it works, step by step
- Each call prints the current number, then delegates to days-1.
- The base case (days == 0) stops the descent.
- Without a base case Python raises RecursionError.
Variable Scope & the LEGB Rule practice
Write a recursive function named countdown. It should print the number, call itself with one less, and stop after printing 0. Call it with 3.
Expected output: 3
2
1
0
Coding Practice is Locked
Sign in to access the interactive Python editor, write and run code checks against automated tests, use hints, and earn XP.
Practice quiz
Check yourself
15 questions with explanations after submission, so you can correct course as you go.
Variable Scope & the LEGB Rule quiz
LockedYou must be signed in to take this quiz. Sign in to test your understanding, track your answers, and earn XP.
Lesson incomplete
Not completedCreate an ID or sign in to save lesson completion across devices.