Variable Scopes, LEGB, and Object Lifetimes
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.pyOutput:
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.pyOutput:
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 = 20Exercises
- 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. - Build a generator or closure using
nonlocalthat generates unique incremental API transaction tokens:TXN-0001,TXN-0002, etc. - Write a small script demonstrating cyclic references between two objects and verify how the
gcmodule 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.