← Python Code Loops, Functions & Scopes
Browse Python Concepts

The LEGB Rule — How Python Resolves Variable Names

Mental Model

Imagine Python compiling a function as building a blueprint for a house. If the blueprint includes a plan for a 'local storage' room for api_endpoint, Python assumes api_endpoint will be local, regardless of whether that room ever gets built (the assignment executes). Any attempt to access api_endpoint before its local storage is 'built' will fail.

Rule: When Python compiles a block, any assignment within that block forces the compiler to treat that variable name as strictly local unless declared with global or nonlocal.

The Setup

You are designing a configuration manager. You want to track an API endpoint globally, but conditionally override or inspect it within nested utility functions.

What Does This Print?

Broken code
Python
api_endpoint = "https://api.production.internal"

def configure_client():
    def check_status():
        # Attempt to log global endpoint, then modify it locally if needed
        print("Checking endpoint:", api_endpoint)
        if False:
            api_endpoint = "https://api.sandbox.internal"
    
    check_status()

configure_client()
Predict what happens when you call configure_client(). Will it print the production endpoint or throw an error?

The Output

What actually happens
UnboundLocalError: local variable 'api_endpoint' referenced before assignment

The program raises an UnboundLocalError. Even though the assignment api_endpoint = ... is inside an if False block that never executes, Python treats api_endpoint as a local variable throughout the entire scope of the check_status function. Thus, the print statement tries to read a local variable that has not been bound yet.

Why Python Does This

During the compilation phase (before execution), Python analyzes the AST (Abstract Syntax Tree) to determine variable scopes. Any assignment within a block (e.g., api_endpoint = ...) flags that variable name as local to the active code block's namespace. The compiler generates bytecode using the LOAD_FAST instruction for that variable. Unlike dynamic lookup languages, Python does not fallback to the enclosing scope at runtime if a local variable is unbound; it immediately raises UnboundLocalError because LOAD_FAST expects the value in the local stack array.

The Fix

Corrected pattern
Python
api_endpoint = "https://api.production.internal"

def configure_client():
    # Pass the endpoint explicitly so check_status has no reason
    # to reach into an outer scope.
    def check_status(endpoint):
        print("Checking endpoint:", endpoint)
    
    check_status(api_endpoint)

configure_client()

Passing api_endpoint as an explicit parameter gives check_status its own local binding from the moment it is called. There is no assignment inside the function body, so Python never treats api_endpoint as local, and the UnboundLocalError cannot occur. Parameter passing is preferred over global because it makes the dependency explicit and avoids coupling the inner function to module-level state.

How This Fails in Real Systems

A payments system utilized a local caching strategy with fallback to global configurations. An unreachable debug-mode assignment branch inside an inner verification loop caused a cascade of UnboundLocalError crashes on production checkout, blocking checkouts for 3 hours until the deployment was rolled back.

Key Takeaway

When Python compiles a block, any assignment within that block forces the compiler to treat that variable name as strictly local unless declared with global or nonlocal.
Common mistake: Developers often assume that a variable's scope is determined only by whether its assignment physically executes, rather than by its mere presence within a function's code block.