Variable Scopes, LEGB, and Object Lifetimes

Updated

September 7, 2026

Variable Scopes, LEGB, and Object Lifetimes

After reading this chapter, you will master Python’s LEGB scope lookup order, control variable rebinding across scopes using global and nonlocal, and understand reference counting lifecycle management.

Mental model

Whenever Python resolves an identifier name in code, it searches four nested lexical scopes in strict order (LEGB):

┌───────────────────────────────────────────────────────────┐
│ Built-in Scope (len, range, print, Exception, id)         │
│  ┌─────────────────────────────────────────────────────┐  │
│  │ Global / Module Scope (top-level functions, vars)   │  │
│  │  ┌───────────────────────────────────────────────┐  │  │
│  │  │ Enclosing / Outer Scope (closures, outer def) │  │  │
│  │  │  ┌─────────────────────────────────────────┐  │  │  │
│  │  │  │ Local Scope (inside the active function)│  │  │  │
│  │  │  │   Search starts here: [1]               │  │  │  │
│  │  │  └────────────────────┬────────────────────┘  │  │  │
│  │  │        Falls back to: [2]                     │  │  │
│  │  └───────────────────────┬───────────────────────┘  │  │
│  │           Falls back to: [3]                        │  │
│  └──────────────────────────┬──────────────────────────┘  │
│              Falls back to: [4]                           │
└───────────────────────────────────────────────────────────┘

If the name is not found in any of the four tiers, Python raises NameError.


Minimal example

Save as legb_demo.py:

# legb_demo.py
# 1. Global scope
app_env = "production"

def outer_service() -> None:
    # 2. Enclosing scope
    service_name = "auth_api"

    def inner_handler() -> None:
        # 3. Local scope
        request_id = "req-9481"
        print(f"Local     : {request_id}")
        print(f"Enclosing : {service_name}")
        print(f"Global    : {app_env}")
        print(f"Built-in  : {len(request_id)}")  # "len" resolved from built-in scope

    inner_handler()

if __name__ == "__main__":
    outer_service()

Run via uv run python legb_demo.py:

Local     : req-9481
Enclosing : auth_api
Global    : production
Built-in  : 8

Worked examples

Case 1: Mutating enclosing state with nonlocal

Without nonlocal, attempting to assign to an enclosing variable creates a new local variable instead. nonlocal binds the assignment directly to the enclosing frame:

# stateful_counter.py
from collections.abc import Callable

def make_counter(start: int = 0) -> Callable[[], int]:
    count = start

    def increment() -> int:
        nonlocal count  # Rebinds the enclosing 'count' variable
        count += 1
        return count

    return increment

if __name__ == "__main__":
    counter = make_counter(start=10)
    print("Call 1:", counter())
    print("Call 2:", counter())
    print("Call 3:", counter())

Run:

uv run python stateful_counter.py

Output:

Call 1: 11
Call 2: 12
Call 3: 13

Case 2: Object lifecycle and reference counting

Python reclaims heap memory as soon as an object’s reference count drops to zero (pymalloc).

# lifecycle_demo.py
import sys

class Resource:
    def __init__(self, name: str) -> None:
        self.name = name
        print(f"Resource '{self.name}' allocated")

    def __del__(self) -> None:
        print(f"Resource '{self.name}' deallocated from memory")

def main() -> None:
    print("Creating resource...")
    res = Resource("DatabaseSocket")
    print(f"Current reference count: {sys.getrefcount(res) - 1}")

    alias = res
    print(f"With alias, reference count: {sys.getrefcount(res) - 1}")

    print("Dropping references...")
    del res
    print("Dropping final alias...")
    del alias
    print("Function complete.")

if __name__ == "__main__":
    main()

Run:

uv run python lifecycle_demo.py

Output:

Creating resource...
Resource 'DatabaseSocket' allocated
Current reference count: 1
With alias, reference count: 2
Dropping references...
Dropping final alias...
Resource 'DatabaseSocket' deallocated from memory
Function complete.

Pitfalls

Pitfall 1: UnboundLocalError caused by shadowing assignment

If a variable is assigned anywhere inside a function, Python marks it as local for the entire function scope. Referencing it before that assignment fails:

# Bug:
count = 10

def increment() -> None:
    print(count)  # UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
    count = 20    # Because assignment exists here, Python treated 'count' as local from line 1!

# Fix: Use global declaration if mutating a global is truly intended:
def increment_fixed() -> None:
    global count
    print(count)
    count = 20

Exercises

  1. Write a function that demonstrates each tier of LEGB by overriding a built-in name (e.g. defining a local len = 100) and verifying the local value shadows the built-in.
  2. Build a generator or closure using nonlocal that generates unique incremental API transaction tokens: TXN-0001, TXN-0002, etc.
  3. Write a small script demonstrating cyclic references between two objects and verify how the gc module detects them.

Further reading

  • Python Language Reference: Section 4.2: Naming and Binding.
  • PEP 3104: Access to Names in Outer Scopes (nonlocal).
  • CPython Internal Documentation: Garbage Collector Design and Reference Counting.