Python Frame Objects and Call Stack Internals
This article explores how Python manages code execution behind the
scenes using frame objects and internal call stacks. It breaks down the
structure of PyFrameObject, details how CPython links
frames together to trace execution flow, examines key attributes such as
local namespaces and bytecode pointers, highlights recent architectural
changes in modern Python versions, and demonstrates how to
programmatically inspect the call stack at runtime.
What Is a Python Frame Object?
In CPython, a frame object (represented internally by the C structure
PyFrameObject) is a runtime data structure created whenever
a code block executes. Functions, module-level code, class definitions,
and generator calls each produce a frame object.
While a code object (PyCodeObject)
contains static, immutable data generated during compilation—such as
bytecode instructions, constant values, and variable names—a
frame object contains the dynamic, mutable state
required to actually execute that code. This state includes:
- Local variables and cell storage: Values assigned to names within the scope.
- Global and builtin namespaces: Dictionaries providing access to module-level and built-in identifiers.
- Execution pointer: The offset of the bytecode
instruction currently being evaluated (
f_lasti). - Evaluation stack: The value stack used by Python's stack-based virtual machine to store intermediate computation results.
How the Call Stack Is Represented Internally
Unlike low-level compiled languages like C or Rust, where the call stack resides in a single, contiguous region of operating system memory, traditional CPython implements the call stack as a linked list of heap-allocated frame objects.
Each frame contains a pointer to the frame that invoked it:
- The Chain of Execution: Every frame has an
f_backattribute. When functionA()calls functionB(), CPython creates a new frame forB()and sets itsf_backpointer toA()'s frame. - Stack Unwinding and Tracing: When
B()finishes or returns a value, the interpreter readsf_backto return control to the caller and restoresA()'s execution context. If an uncaught exception occurs, Python walks backward along thef_backpointers to construct the traceback message. - Thread State: The active frame at the top of the
execution stack for any given thread is tracked by the thread's state
structure (
PyThreadState->cframeorcurrent_frame).
Anatomy of a Frame Object
At the Python level, frame objects expose several read-only and mutable attributes:
f_back: The previous execution frame (the caller), orNoneif the frame is at the base of the stack.f_code: The underlying code object being executed.f_locals: A dictionary mapping local variable names to their current values.f_globals: The dictionary representing the global namespace of the module.f_builtins: The dictionary of built-in names (e.g.,len,range).f_lasti: The "last instruction"—an integer index representing the bytecode instruction currently executing.f_lineno: The current line number in the source file corresponding tof_lasti.f_trace: A callback function invoked on tracing events (used by debuggers likepdband coverage tools).
Changes in Python
3.11+: _PyInterpreterFrame
Starting in Python 3.11, CPython overhauled frame management to reduce overhead and improve execution speed.
Historically, allocating a full heap-based PyFrameObject
for every function call introduced significant memory allocation
overhead. Modern CPython uses a lighter internal structure called
_PyInterpreterFrame.
These lightweight frames are allocated contiguously in chunks on a
dedicated evaluation stack per thread. A full Python-visible
PyFrameObject is now created lazily—only when explicitly
requested by debugging utilities, profiling tools, or introspection
calls. When a function returns without its frame ever being inspected,
the internal frame data is discarded without triggering a generic heap
allocation.
Inspecting Frames and the Call Stack
Python allows runtime inspection of frame objects via the
sys and inspect modules:
import sys
import inspect
def inner():
# Retrieve the current frame directly from the interpreter
current_frame = sys._getframe()
print(f"Current Function: {current_frame.f_code.co_name}")
print(f"Current Line: {current_frame.f_lineno}")
print(f"Caller Function: {current_frame.f_back.f_code.co_name}")
# Inspecting the entire stack chain
caller = current_frame.f_back
while caller:
print(f"Stack Frame: {caller.f_code.co_name} in {caller.f_code.co_filename}")
caller = caller.f_back
def outer():
inner()
outer()The higher-level inspect module provides utility
functions like inspect.currentframe() and
inspect.stack(), which wrap these frame pointers into
structured records containing file paths, context lines, and
positions.