Namespaces, Scope Resolution & Frame Stacks

Master lexical scope resolution in CPython: LEGB hierarchy formalization, PyFrameObject architecture, symbol table classification, and LOAD_FAST optimization.

01. CPython Architecture, Object Model & Type System Mechanics7 min read

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 AccessPerformance
O(1)

Local variable lookups bypass hash tables entirely via the contiguous fastlocals frame array.

1. The LEGB Scope Resolution Hierarchy#

When an identifier name NN is evaluated in Python, the interpreter searches four nested lexical namespaces in strict order:

Architecture Diagram
Rendering diagram...

Mathematical Formalization#

Let L(N)\mathcal{L}(N) represent the name resolution function for string identifier NN:

\mathbf{Local}(N) & \text{if } N \in \text{Domain}(\mathbf{Local}) \\ \mathbf{Enclosing}_k(N) & \text{if } N \notin \text{Domain}(\mathbf{Local}) \land N \in \text{Domain}(\mathbf{Enclosing}_k) \\ \mathbf{Global}(N) & \text{if } N \notin \text{Domain}(\mathbf{Local} \cup \mathbf{Enclosing}) \land N \in \text{Domain}(\mathbf{Global}) \\ \mathbf{Builtin}(N) & \text{if } N \notin \text{Domain}(\mathbf{Local} \cup \mathbf{Enclosing} \cup \mathbf{Global}) \land N \in \text{Domain}(\mathbf{Builtin}) \\ \bot \; (\text{NameError}) & \text{otherwise}

Where Enclosingk\mathbf{Enclosing}_k 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 ClassScope ConditionOpcode UsedAccess Mechanism
FAST_LOCALAssigned inside a function bodyLOAD_FAST / STORE_FASTDirect integer offset in C array
DEREF / CELLReferenced across nested closure scopesLOAD_DEREF / STORE_DEREFPointer to PyCellObject wrapper
GLOBALDeclared global or assigned at module levelLOAD_GLOBAL / STORE_GLOBALHash lookup in module __dict__
BUILTINFallback when absent in globalsLOAD_GLOBAL (with built-in fallback)Hash lookup in builtins.__dict__
The UnboundLocalError Trap
Because variable scope classification happens at compile time, if a variable is assigned anywhere within a function body, the compiler marks that variable as FAST_LOCAL for the entire function!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
counter = 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:

Architecture Diagram
Rendering diagram...

The localsplus array is allocated contiguously at the end of the frame struct. It holds:

  1. Positional Arguments & Local Variables (fastlocals): Accessed via simple zero-based array indices.
  2. Cell & Free Variables: Pointers to shared PyCellObject instances enabling lexical closures.
  3. 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:

Architecture Diagram
Rendering diagram...

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:

Concept check

Why does Python raise an UnboundLocalError when referencing a global variable if that same variable name is assigned later in the function?

Concept check

How does CPython preserve variables across lexical closures after the enclosing outer function has returned?