Namespaces, Scope Resolution & Frame Stacks
Master lexical scope resolution in CPython: LEGB hierarchy formalization, PyFrameObject architecture, symbol table classification, and LOAD_FAST optimization.
In Python, variables are not typed memory slots; they are dynamic reference names bound to heap-allocated objects inside namespaces. How a name resolves to an object depends on lexical scope rules, compile-time symbol analysis, and the runtime frame evaluation stack.
Understanding these mechanics demystifies common pitfalls such as UnboundLocalError and illustrates how CPython optimizes function execution.
Local variable lookups bypass hash tables entirely via the contiguous fastlocals frame array.
1. The LEGB Scope Resolution Hierarchy#
When an identifier name is evaluated in Python, the interpreter searches four nested lexical namespaces in strict order:
Mathematical Formalization#
Let represent the name resolution function for string identifier :
Where scans outwards from innermost to outermost enclosing closure scopes.
2. Compile-Time Symbol Table Analysis#
Before emitting any bytecode, the CPython compiler executes a dedicated symbol table analysis pass over the Abstract Syntax Tree.
During this pass, every variable name in a code block is classified into one of four scope classes:
| Scope Class | Scope Condition | Opcode Used | Access Mechanism |
|---|---|---|---|
FAST_LOCAL | Assigned inside a function body | LOAD_FAST / STORE_FAST | Direct integer offset in C array |
DEREF / CELL | Referenced across nested closure scopes | LOAD_DEREF / STORE_DEREF | Pointer to PyCellObject wrapper |
GLOBAL | Declared global or assigned at module level | LOAD_GLOBAL / STORE_GLOBAL | Hash lookup in module __dict__ |
BUILTIN | Fallback when absent in globals | LOAD_GLOBAL (with built-in fallback) | Hash lookup in builtins.__dict__ |
FAST_LOCAL for the entire function!1234567891011121314counter = 10 def increment(): # Reading counter triggers UnboundLocalError! # Because 'counter = counter + 1' assigns to counter below, # the compiler classified 'counter' as a local variable for this entire function. print(counter) counter = 20 # To fix, declare explicitly: def increment_fixed(): global counter print(counter) counter += 1
3. The PyFrameObject Memory Layout#
Each time a Python function is invoked, CPython creates a PyFrameObject representing its execution environment:
The localsplus array is allocated contiguously at the end of the frame struct. It holds:
- Positional Arguments & Local Variables (
fastlocals): Accessed via simple zero-based array indices. - Cell & Free Variables: Pointers to shared
PyCellObjectinstances enabling lexical closures. - The Value Stack: LIFO evaluation stack where bytecode operands and intermediate results are pushed and popped.
4. Closures and PyCellObject#
When an inner function references a variable from an enclosing outer function, the variable cannot live purely on the stack of the outer function because the outer frame is destroyed when it returns.
CPython solves this by wrapping the variable in a PyCellObject:
Even after make_multiplier() finishes execution and its stack frame is deallocated, the PyCellObject remains live on the heap as long as the returned inner closure retains a reference!
5. Concept Verification#
Test your understanding of CPython namespaces and frame mechanics:
Why does Python raise an UnboundLocalError when referencing a global variable if that same variable name is assigned later in the function?
How does CPython preserve variables across lexical closures after the enclosing outer function has returned?