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

  1. Creation — When a function is called, CPython allocates a frame (or reuses one from the free list). It initializes f_locals with argument values, sets f_lasti to -1 (no instruction executed yet), and links f_back to the caller’s frame.

  2. Execution — The interpreter loop (_PyEval_EvalFrameDefault in Python/ceval.c) reads bytecode instructions from f_code.co_code, advancing f_lasti after each instruction.

  3. Suspension (generators/coroutines only) — When a generator yields, the frame is detached from the call stack but kept alive. f_lasti records where to resume. The evaluation stack contents are preserved.

  4. 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 PyFrameObject is 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.

pythoncompiler-internalslanguage-implementation

See Also