Skip to contentSkip to navigation
Module 05 • Lesson 04intermediate 20 mins read

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.

L - Local
Current function frame
E - Enclosing
Outer nested functions
G - Global
Module namespace
B - Built-in
Universal Python built-ins

02 / Scope Directives & Syntax Anatomy

Python looks outward automatically to read variables, but modifying or reassigning an outer variable requires explicit declaration keywords.

The global KeywordModule Rebinding
total_requests = 0  # Global scope

def handle_request():
    global total_requests
    total_requests += 1  # Reassigns module variable

Instructs the compiler that assignments to total_requests target the module's global __dict__, not a new local variable.

The nonlocal KeywordLexical Closures
def make_counter():
    count = 0  # Enclosing scope
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

Binds 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:

1. Fastlocals & LOAD_FAST

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.

2. Closures & PyCellObject

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.

3. Compile-Time Assignment Flag

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.

4. Recursion & No TCO

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 / KeywordSearch OrderStorage MechanismRebinding SyntaxCPython Bytecode
Local (L)1stFrame fastlocals arrayx = valLOAD_FAST
Enclosing (E)2ndHeap PyCellObjectnonlocal xLOAD_DEREF
Global (G)3rdModule __dict__ hash tableglobal xLOAD_GLOBAL
Built-in (B)4thbuiltins module dictRead-onlyLOAD_GLOBAL (fallback)

05 / Official Syntax Reference

Local (L)1st (highest)

Exists only during function execution frame; stored in fastlocals.

def f(): x = 1
Enclosing (E) / nonlocal2nd

Stored in heap cell object; binds to nearest enclosing function scope.

def outer(): x=0; def inner(): nonlocal x; x+=1
Global (G) / global3rd

Module-level namespace dictionary; persists as long as module is loaded.

global counter; counter += 1
Built-in (B)4th (lowest)

Built-in functions and types; universally available across all modules.

len([1, 2, 3])
Recursion Base CaseN/A

Halts call stack expansion; provides terminal calculation result.

if n == 0: return 0

06 / Tiered Code Walkthroughs & Worked Examples

The LEGB Lookup Hierarchy in Action

Core

Demonstrating how Python resolves variables through Local, Enclosing, Global, and Built-in scopes.

The LEGB Lookup Hierarchy in Action Run cleanly to earn +1 XP
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)
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

Name Shadowing vs. Modifying Globals with the global Keyword

Core

Showing how local variable assignment shadows globals by default, and how the global keyword permits reassigning module state.

Name Shadowing vs. Modifying Globals with the global Keyword Run cleanly to earn +1 XP
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)
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

Stateful Closures Using the nonlocal Keyword

Core

Retaining and mutating state across function calls using enclosing lexical scope and nonlocal.

Stateful Closures Using the nonlocal Keyword Run cleanly to earn +1 XP
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
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

Inspecting Scopes with globals() and locals()

Core

Examining the active namespace dictionaries at module level and inside execution frames.

Inspecting Scopes with globals() and locals() Run cleanly to earn +1 XP
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)
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

Anatomy of Recursion: Factorial Calculation

Core

Understanding the base case stopping condition and recursive step unwinding.

Anatomy of Recursion: Factorial Calculation Run cleanly to earn +1 XP
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)
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

Deep Tree Traversal via Recursion

Core

Summing values in arbitrarily nested data structures using recursive case dispatch.

Deep Tree Traversal via Recursion Run cleanly to earn +1 XP
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))
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

The Recursion Limit and RecursionError Safeguard

Core

Inspecting sys.getrecursionlimit() and catching RecursionError when a base case is omitted.

The Recursion Limit and RecursionError Safeguard Run cleanly to earn +1 XP
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)
Run cleanly to earn +1 XP: Select Run (or edit code first to experiment)! Runs that end in an error earn no XP.

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.

Active Scopes Defining 'x'
Scope Directive Keyword
Compiler Edge Scenario

Simulates reading x before its assignment inside the same function body.

Resolution Pipeline
1. Local (L)
2. Enclosing (E)
3. Global (G)
4. Built-in (B)
Resolution: Local (L)
Bytecode: LOAD_FAST

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.

Step 0 of 6
_PyInterpreterFrame Stack (Top = Active Frame)Total Stack Depth: 0
Click “Next Step” to begin pushing frames onto the call stack.

09 / Common Anti-Patterns & Pitfalls

🟢 Beginner Pitfall: The UnboundLocalError Trap

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.

Flawed Implementation
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()
Correct Pattern
count = 10
def increment():
    global count  # Explicitly declares intent to modify module global
    count = count + 1

increment()
print('Count successfully incremented:', count)
🟢 Beginner Pitfall: Overusing the global Keyword for Data Flow

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.

Flawed Implementation
# Bad: hidden global mutations
result = None
def calculate_tax(amount):
    global result
    result = amount * 0.08
Correct Pattern
# Good: pure function with inputs and output
def calculate_tax(amount):
    return amount * 0.08

tax = calculate_tax(100)
🟡 Intermediate Pitfall: Modifying locals() Expecting to Change Local Variables

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.

Flawed Implementation
def attempt_local_mutation():
    x = 10
    locals()['x'] = 99  # Does NOT update local x!
    print('x value:', x)  # Still 10!

attempt_local_mutation()
Correct Pattern
def direct_local_assignment():
    x = 10
    x = 99  # Directly assign to update
    print('x value:', x)

direct_local_assignment()
🟡 Intermediate Pitfall: Missing or Incorrect Recursive Base Case

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.

Flawed Implementation
# Bug: calling countdown(n) without decrementing input
def countdown(n):
    if n == 0: return
    # countdown(n) -> Infinite loop!
Correct Pattern
def countdown(n):
    if n <= 0:
        return
    countdown(n - 1)  # Strictly progresses toward base case

10 / Compiler Edge Cases & Quirks

Mutating Mutable Globals Without the global Keyword

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)
Output: Global cache mutated: ['entry_1']
Late-Binding Closures Inside Loops

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])
Output: Flawed closures: [2, 2, 2] Fixed closures: [0, 1, 2]
nonlocal Requires an Existing Enclosing Variable

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.')
Output: nonlocal cannot target global scope.
Tail Recursion Stack Accumulation

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())
Output: Recursion limit: 1000

11 / Execution Performance & Memory Tradeoffs

recursion vs iteration

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 vs load global

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.

closure cell overhead

Using nonlocal and closures places variables inside PyCellObject heap allocations rather than direct stack slots, slightly increasing dereferencing time.

recursion depth tuning

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

Rule 1: Pure Functions First

Always pass parameters in and return values out. Avoid mutating module globals or using global unless managing singleton lifecycle state.

Rule 2: Closures for Factories

Use nonlocal inside nested function factories (e.g. rate limiters, caching decorators) where persistent private state is required without building a class.

Rule 3: Tail Loops > Deep Recursion

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

System Engineering

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.

System Engineering

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.

System Engineering

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

17 Questions
Question 1 of 17 • easy
What is a local variable?
Question 2 of 17 • easy
What is the base case in recursion for?
Question 3 of 17 • medium
What can happen if recursive calls never reach a base case?
Question 4 of 17 • easy
What does this print?
message = 'outside'

def show():
    message = 'inside'
    print(message)

show()
print(message)
Question 5 of 17 • easy
What does this print?
score = 0

def add_point():
    global score
    score = score + 1

add_point()
print(score)
Question 6 of 17 • medium
What does this print?
def count(number):
    if number == 0:
        return 0
    return 1 + count(number - 1)

print(count(3))
Question 7 of 17 • medium
Which line is the bug if a function must reassign the global score variable?
Question 8 of 17 • medium
Which condition is a base case for a countdown function that stops at zero?
Question 9 of 17 • hard
What does this print?
color = 'blue'

def paint(color):
    print(color)

paint('red')
print(color)
Question 10 of 17 • hard
What does this print?
def factorial(number):
    if number == 0:
        return 1
    return number * factorial(number - 1)

print(factorial(4))
Question 11 of 17 • easy
What does the LEGB rule stand for in Python name resolution?
Question 12 of 17 • hard
Why does `def f(): y = x; x = 5` raise UnboundLocalError when global `x = 10` exists?
Question 13 of 17 • medium
What keyword is used to modify a variable in an outer (enclosing) function from an inner nested function?
Question 14 of 17 • medium
What is the default recursion limit in standard CPython?
Question 15 of 17 • hard
Does calling `items.append(1)` on a module-level list `items = []` inside a function require `global items`?
Question 16 of 17 • hard
What does Python's `locals()` function return when called inside a function?
Question 17 of 17 • hard
Why does Python not optimize tail-recursive calls into loops automatically?

15 / Guided Practice Labs & Test Suites

Lab 1

Summing Arbitrarily Nested Lists Recursively

Difficulty: easy

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.

Starter Code
def deep_sum(nested_list):
    # Sum all integers in arbitrarily nested list
    pass
Automated Verification Assertions
assert deep_sum([1, [2, [3, 4], 5], 6]) == 21
assert deep_sum([]) == 0
assert deep_sum([[10], [20, [30]]]) == 60
Lab 2

Stateful Accumulator Factory with nonlocal

Difficulty: medium

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.

Starter Code
def make_accumulator(initial=0):
    # Return accumulator function maintaining state via nonlocal
    pass
Automated Verification Assertions
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
Lab 3

Recursive String Inversion

Difficulty: hard

Write a recursive function reverse_string(s) that reverses a string without using slicing ([::-1]) or the built-in reversed() function.

Starter Code
def reverse_string(s):
    # Reverse string recursively
    pass
Automated Verification Assertions
assert reverse_string('hello') == 'olleh'
assert reverse_string('') == ''
assert reverse_string('a') == 'a'
assert reverse_string('python') == 'nohtyp'

16 / Production Debugging Lab

Issue: UnboundLocalError in Simple Counter Increment
Symptom: UnboundLocalError: cannot access local variable 'total' where it is not associated with a value.
Root Cause: The function read total += delta. Because total was assigned to, Python treated it as a local variable before it was initialized.
Resolution: Add global total at the beginning of the function or pass total into the function and return the updated total.
Issue: RecursionError: maximum recursion depth exceeded while getting the str of an object
Symptom: A tree processing script crashed with a RecursionError during deep traversal.
Root Cause: A cycle existed in the node graph (Node A -> Node B -> Node A), causing the recursive function to loop infinitely.
Resolution: Pass a set of visited node IDs (visited=None) through the recursive calls and return immediately if node_id in visited.

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.

Example
def countdown(days):
    if days == 0:
        return "Exam day!"
    print(days)
    return countdown(days - 1)

print(countdown(3))

How it works, step by step

  1. Each call prints the current number, then delegates to days-1.
  2. The base case (days == 0) stops the descent.
  3. Without a base case Python raises RecursionError.

Your notes

Sign in to keep private notes alongside this lesson.

Sign in to take notes

Variable Scope & the LEGB Rule practice

Sign-in required

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

Locked

You must be signed in to take this quiz. Sign in to test your understanding, track your answers, and earn XP.

Lesson incomplete

Not completed

Create an ID or sign in to save lesson completion across devices.

Create ID to save
Module lesson progress0/6 complete
Module cheat sheet