Scope — Local, Enclosing, Global and Built-in (LEGB)

Every name in Python is resolved by searching through a hierarchy of scopes — the places where Python looks for the value of a variable. Python uses the LEGB rule: it searches Local scope first, then Enclosing (outer function) scope, then Global (module-level) scope, then Built-in scope. Understanding LEGB is essential for working with FastAPI’s dependency injection, closures, and module-level configuration objects. The most common scope-related bugs — accidentally shadowing a built-in name, misusing global variables, or being surprised by closure behaviour — all become obvious once you understand LEGB.

The LEGB Rule

# Python searches scopes in this order: Local → Enclosing → Global → Built-in

# ── Built-in scope — always available ─────────────────────────────────────────
# print, len, range, type, int, str, list, dict, set, None, True, False ...
print(len([1, 2, 3]))   # len and print are built-in names

# ── Global scope — module level ───────────────────────────────────────────────
DATABASE_URL = "postgresql://localhost/mydb"   # global variable

def connect():
    print(DATABASE_URL)   # reads the global — no global keyword needed for reading

connect()   # postgresql://localhost/mydb

# ── Local scope — inside a function ───────────────────────────────────────────
def process():
    result = 42            # local to process()
    print(result)

process()   # 42
# print(result)   # NameError — result does not exist outside the function

# ── Enclosing scope — outer function's locals ─────────────────────────────────
def outer():
    message = "Hello from outer"   # enclosing scope for inner()

    def inner():
        print(message)   # found in enclosing scope — no error

    inner()

outer()   # Hello from outer
Note: You can read a global variable inside a function without any special keyword — Python finds it through the LEGB rule. But if you try to assign a value to a global name inside a function without the global keyword, Python creates a new local variable with that name instead of modifying the global. This is a frequent source of confusion: the function appears to read the global but then creates a local copy when assigning, which hides the global for the rest of the function body.
Tip: In FastAPI applications, avoid relying on global variables for mutable application state (like a database connection pool or cached data). Instead, use FastAPI’s dependency injection system (Depends()) or application state (app.state). Module-level constants (configuration values that never change after startup) are fine as globals — the issue is with mutable globals that different parts of the code can modify in unexpected ways.
Warning: Never shadow Python’s built-in names with variable names. Naming a variable list, type, id, input, print, or filter overwrites the built-in in that scope, and any code that tries to use the built-in function will find your variable instead. FastAPI developers most commonly shadow id (very common as a parameter name in route handlers) and filter. Use post_id, user_id, query_filter instead.

The global Keyword

counter = 0   # global variable

def increment():
    global counter          # tell Python: 'counter' refers to the global
    counter += 1

def read_counter():
    print(counter)          # reading global — no keyword needed

increment()
increment()
increment()
read_counter()   # 3

# Without 'global', assignment creates a local:
def broken_increment():
    counter += 1   # UnboundLocalError! Python sees the assignment and treats
                   # 'counter' as local, but it's used before being assigned locally

# ── The pattern that trips up JS developers ────────────────────────────────────
x = 10

def show():
    print(x)   # reads global x — fine

def modify():
    x = 20     # creates LOCAL x — does NOT modify global
    print(x)   # prints 20 (local)

show()    # 10
modify()  # 20
show()    # 10 — global unchanged!

The nonlocal Keyword

# nonlocal — modify a variable in the enclosing (outer function) scope

def make_counter(start=0):
    count = start                # enclosing scope variable

    def increment():
        nonlocal count           # refers to enclosing 'count', not global
        count += 1
        return count

    def reset():
        nonlocal count
        count = start

    return increment, reset      # return both functions (closure)

# Create a counter
inc, rst = make_counter(0)
print(inc())   # 1
print(inc())   # 2
print(inc())   # 3
rst()
print(inc())   # 1 — reset worked

# Without nonlocal, this would fail:
def broken():
    count = 0
    def inc():
        count += 1   # UnboundLocalError — count is treated as local to inc()

Closures — Functions That Remember

# A closure is a function that captures variables from its enclosing scope
# The captured variables persist even after the outer function returns

def multiplier(factor):
    def multiply(number):
        return number * factor   # 'factor' is captured from enclosing scope
    return multiply

double = multiplier(2)    # factor=2 is captured
triple = multiplier(3)    # factor=3 is captured

print(double(5))   # 10
print(triple(5))   # 15
print(double(7))   # 14

# FastAPI uses closures for dependency factories:
def get_settings(env: str):
    def load():
        return Settings(environment=env)   # env captured from outer scope
    return load

# Each environment gets its own settings loader
prod_settings  = get_settings("production")
dev_settings   = get_settings("development")

LEGB Lookup — Step by Step

x = "global"                    # 3. Global scope

def outer():
    x = "enclosing"             # 2. Enclosing scope

    def inner():
        x = "local"             # 1. Local scope
        print(x)                # "local" — found in L

    def inner_no_local():
        print(x)                # "enclosing" — found in E (no local x)

    inner()             # local
    inner_no_local()    # enclosing

outer()

# Remove the global x — then inner_no_local would find it in E still.
# Remove both global and enclosing — would be NameError (not in B either)

Common Mistakes

Mistake 1 — Shadowing built-in names

❌ Wrong — overwriting the built-in list:

list = [1, 2, 3]          # shadows the built-in list type
new = list([4, 5, 6])     # TypeError — list is now [1,2,3], not the constructor

✅ Correct — use descriptive names:

post_ids = [1, 2, 3]       # ✓ descriptive, no shadowing

Mistake 2 — Expecting assignment inside a function to modify a global

❌ Wrong — assumes global is modified:

count = 0
def increment():
    count = count + 1   # UnboundLocalError: count referenced before assignment

✅ Correct — declare global intent:

def increment():
    global count
    count = count + 1   # ✓

Mistake 3 — Loop variable capture in closures (classic bug)

❌ Wrong — all closures capture the same variable:

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])   # [2, 2, 2] — all capture the final value of i

✅ Correct — capture by default argument:

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2] ✓

Quick Reference

Scope Where Access
L — Local Inside current function Read/write freely
E — Enclosing Outer function’s locals Read freely; write with nonlocal
G — Global Module level Read freely; write with global
B — Built-in Python builtins Read freely; avoid shadowing

🧠 Test Yourself

What does this code print, and why?
x = 1
def f():
    x = 2
f()
print(x)