Frame Objects — Deep Dive
Frame Objects in CPython’s Internals
In CPython, frame objects are instances of PyFrameObject (defined in Include/cpython/frameobject.h). Starting with Python 3.11, CPython underwent a major refactoring of frame internals to improve performance. The interpreter now uses a “lightweight” internal frame representation (_PyInterpreterFrame) for speed, and only creates the full PyFrameObject when Python code actually requests it (via sys._getframe(), inspect, or traceback handling).
This lazy materialization means that in hot loops where no one inspects frames, the overhead of frame creation is significantly reduced.
The Evaluation Stack
Each frame contains an evaluation stack — a LIFO data structure where bytecode instructions push and pop values. When the interpreter executes LOAD_FAST followed by LOAD_FAST followed by BINARY_OP, the two values are pushed onto the stack, then the binary operation pops them, computes the result, and pushes the result back.
The maximum stack depth is pre-computed at compile time and stored in co_stacksize. The interpreter allocates stack space for the frame accordingly.
import dis
def compute(a, b, c):
return (a + b) * c
dis.dis(compute)
# LOAD_FAST 0 (a) stack: [a]
# LOAD_FAST 1 (b) stack: [a, b]
# BINARY_OP 0 (+) stack: [a+b]
# LOAD_FAST 2 (c) stack: [a+b, c]
# BINARY_OP 5 (*) stack: [(a+b)*c]
# RETURN_VALUE returns top of stack
Frame Lifecycle
-
Creation — When a function is called, CPython allocates a frame (or reuses one from the free list). It initializes
f_localswith argument values, setsf_lastito -1 (no instruction executed yet), and linksf_backto the caller’s frame. -
Execution — The interpreter loop (
_PyEval_EvalFrameDefaultinPython/ceval.c) reads bytecode instructions fromf_code.co_code, advancingf_lastiafter each instruction. -
Suspension (generators/coroutines only) — When a generator yields, the frame is detached from the call stack but kept alive.
f_lastirecords where to resume. The evaluation stack contents are preserved. -
Destruction — When the function returns (or an unhandled exception propagates out), the frame is unlinked from the call stack. If no other reference exists, it is deallocated (or returned to the free list).
sys.settrace() and Frame Tracing
The trace function mechanism is how debuggers, profilers, and coverage tools hook into Python’s execution:
import sys
def trace_calls(frame, event, arg):
if event == 'call':
print(f"Calling {frame.f_code.co_name} "
f"from {frame.f_back.f_code.co_name}:{frame.f_back.f_lineno}")
elif event == 'line':
print(f" Line {frame.f_lineno} in {frame.f_code.co_name}")
elif event == 'return':
print(f" Return from {frame.f_code.co_name}: {arg}")
return trace_calls
sys.settrace(trace_calls)
def greet(name):
greeting = f"Hello, {name}"
return greeting
greet("World")
sys.settrace(None)
The trace function receives three events per function: call (entering), line (each source line), and return (leaving). For exceptions, there is also an exception event. The trace function must return itself (or a new function) to continue tracing, or None to stop.
Performance impact: Setting a trace function slows execution dramatically (often 10-100x) because the interpreter must call back into Python for every line. Python 3.12 introduced sys.monitoring as a lower-overhead alternative that uses event-based hooks with less per-line cost.
Generator and Coroutine Frames
Generators keep their frame alive between yield calls. This is fundamentally different from normal functions, where the frame is created and destroyed within a single call:
def counter():
n = 0
while True:
n += 1
yield n
gen = counter()
print(next(gen)) # 1
print(gen.gi_frame.f_locals) # {'n': 1}
print(next(gen)) # 2
print(gen.gi_frame.f_locals) # {'n': 2}
The generator object holds a reference to its frame via gi_frame. When the generator is exhausted or garbage-collected, the frame is finalized (executing any finally blocks) and then destroyed.
Async coroutines work identically — the coroutine object holds cr_frame, and await suspends the frame just like yield.
Frame Locals vs. Fast Locals
CPython stores local variables in a C array (the “fast locals”) for performance. The f_locals dictionary is a separate object that is populated on demand by copying from the fast array. This creates a subtle trap:
import sys
def tricky():
x = 10
frame = sys._getframe()
frame.f_locals['x'] = 99
print(x) # Still prints 10!
tricky()
Modifying f_locals does not update the fast locals array. In Python 3.13, PEP 667 changed f_locals to return a proxy that writes through to the fast locals, fixing this long-standing inconsistency.
Recursion and Stack Limits
Each function call adds a frame to the call stack. Python limits recursion depth (default 1000) via sys.getrecursionlimit(). Exceeding this raises RecursionError. The limit exists because each frame consumes memory (both the Python frame and the C stack frame that the interpreter loop uses).
import sys
sys.setrecursionlimit(5000) # increase if needed
# But beware: very deep recursion can segfault
# if the C stack overflows before Python catches it
Python 3.12 reduced the C stack consumption per frame, allowing deeper recursion for the same C stack size.
Frame Introspection Patterns
Walking the call stack:
import sys
def get_call_chain():
chain = []
frame = sys._getframe(1)
while frame:
chain.append(f"{frame.f_code.co_filename}:"
f"{frame.f_lineno}:{frame.f_code.co_name}")
frame = frame.f_back
return chain
Finding the caller’s module:
def caller_module():
frame = sys._getframe(1)
return frame.f_globals.get('__name__', '<unknown>')
Capturing local state for logging:
def debug_snapshot():
frame = sys._getframe(1)
return {
'function': frame.f_code.co_name,
'line': frame.f_lineno,
'locals': dict(frame.f_locals),
}
Memory and Reference Cycles
Frames can create reference cycles. A common pattern: an exception traceback holds a reference to a frame, the frame holds f_locals, and f_locals holds the exception object. Python’s cyclic garbage collector handles this, but in performance-sensitive code, you should explicitly clear traceback references:
try:
risky_operation()
except Exception as e:
log_error(e)
# Clear the traceback to break the reference cycle
e.__traceback__ = None
Or use traceback.clear_frames(tb) to clear all frames in a traceback chain.
Python 3.11+ Frame Optimizations
The interpreter in Python 3.11 introduced several frame-related optimizations:
- Lazy frame creation — Full
PyFrameObjectis only materialized when requested - Inline frames — The interpreter can execute calls without creating a full frame for simple cases
- Quickening — Bytecode instructions are specialized at runtime based on observed types, and this specialization is tracked per-code-object rather than per-frame
These changes contributed to the 10-60% speedup in Python 3.11 compared to 3.10.
One thing to remember: Frame objects are where Python’s static code descriptions meet dynamic runtime execution. They hold the living state of every function call — local variables, the instruction pointer, and the evaluation stack. Understanding frames gives you the tools to build debuggers, profilers, and introspection utilities, and helps you reason about Python’s memory behavior, recursion limits, and generator mechanics.
See Also
- Python Abstract Syntax Trees How Python reads your code like a recipe before cooking it — the hidden tree structure behind every program.
- Python Bytecode Manipulation How Python secretly translates your code into tiny instructions — and how you can peek at and change those instructions yourself.
- Python Code Objects Internals What Python actually creates when it reads your function — the hidden blueprint that tells the computer what to do.
- Python Compiler Pipeline The journey your Python code takes from text file to running program — explained like an assembly line.
- Python Peephole Optimizer How Python quietly tidies up your code behind the scenes — making it faster without you lifting a finger.