CPython's Frame Implementation in C (types.FrameType)

The PyFrameObject / _PyInterpreterFrame pair, the per-thread frame stack, the eval-frame function, fast locals, generator frames, and frame objects traced through the CPython 3.15 source.

Every Python function call creates an activation record, commonly called a frame. It holds the code being executed, the local variables, the value stack, the globals/builtins, the instruction pointer, and the link to the previous frame. In CPython 3.15 a frame is split into two cooperating structures: the lightweight _PyInterpreterFrame that the VM actually runs on, and the heap-allocated PyFrameObject (exposed to Python as types.FrameType) that is created only when a frame becomes visible to Python code.

This article traces both structs and the machinery around them through the 3.15 source.

The two structs

_PyInterpreterFrame is the working frame. It lives on the per-thread data stack (or inside a generator) and is what the interpreter manipulates:

c
// Include/internal/pycore_interpframe_structs.h
struct _PyInterpreterFrame {
    _PyStackRef f_executable;   /* code object (or None) */
    struct _PyInterpreterFrame *previous;
    _PyStackRef f_funcobj;      /* function object */
    PyObject *f_globals;       /* borrowed */
    PyObject *f_builtins;      /* borrowed */
    PyObject *f_locals;        /* strong, may be NULL */
    PyFrameObject *frame_obj;  /* strong, may be NULL */
    _Py_CODEUNIT *instr_ptr;   /* current/next instruction */
    _PyStackRef *stackpointer;
    uint16_t return_offset;    /* where RETURN lands in caller */
    char owner;                /* FRAME_OWNED_BY_* */
    /* Locals and value stack follow. */
    _PyStackRef localsplus[1];
};

PyFrameObject is the Python-visible object. It is thin and mostly delegates to the interpreter frame it points at:

c
// Include/internal/pycore_frame.h
struct _frame {                 /* aka PyFrameObject */
    PyObject_HEAD
    PyFrameObject *f_back;      /* previous frame, or NULL */
    _PyInterpreterFrame *f_frame; /* points to the frame data */
    PyObject *f_trace;          /* trace function */
    int f_lineno;               /* current line, valid if non-zero */
    char f_trace_lines;
    char f_trace_opcodes;
    PyObject *f_extra_locals;   /* dict for user-set locals */
    PyObject *f_locals_cache;   /* borrowed-ref holder for PyEval_GetLocals */
    PyObject *f_overwritten_fast_locals;
    PyObject *_f_frame_data[1]; /* owned interpreter frame, if any */
};

The split exists for performance: most frames never need a PyFrameObject, so the VM runs on the compact _PyInterpreterFrame and only materializes the heap object on demand (backtraces, sys._getframe(), frame attributes).

Frame ownership (owner)

A frame’s owner field says who is responsible for its lifetime:

c
// Include/internal/pycore_interpframe_structs.h
enum _frameowner {
    FRAME_OWNED_BY_THREAD = 0,        /* on the per-thread stack */
    FRAME_OWNED_BY_GENERATOR = 1,     /* embedded in a gen/coro */
    FRAME_OWNED_BY_FRAME_OBJECT = 2,  /* copied into a PyFrameObject */
    FRAME_OWNED_BY_INTERPRETER = 3,   /* base/sentinel frame */
};

This matters because a frame can outlive its activation: if a PyFrameObject escapes, the interpreter frame is copied into the frame object’s _f_frame_data and the owner becomes FRAME_OWNED_BY_FRAME_OBJECT (see take_ownership() in Python/frame.c).

The frame stack

Each PyThreadState carries a pointer to the currently executing frame and a data stack used to allocate frames:

c
// Include/cpython/pystate.h
struct _ts {
    ...
    struct _PyInterpreterFrame *current_frame;
    struct _PyInterpreterFrame *base_frame;   /* sentinel at the bottom */
    _PyStackChunk *datastack_chunk;
    PyObject **datastack_top;
    PyObject **datastack_limit;
    ...
};

Frames are allocated contiguously in chunks. _PyThreadState_PushFrame either bumps datastack_top by the code object’s co_framesize or allocates a new chunk:

c
// Python/pystate.c
_PyInterpreterFrame *
_PyThreadState_PushFrame(PyThreadState *tstate, size_t size)
{
    if (_PyThreadState_HasStackSpace(tstate, (int)size)) {
        _PyInterpreterFrame *res = (_PyInterpreterFrame *)tstate->datastack_top;
        tstate->datastack_top += size;
        return res;
    }
    return (_PyInterpreterFrame *)push_chunk(tstate, (int)size);
}

The base frame is a sentinel embedded in _PyThreadStateImpl with owner == FRAME_OWNED_BY_INTERPRETER; profilers walk previous until they reach it and verify the address against tstate->base_frame.

When a frame returns, _PyThreadState_PopFrame either resets datastack_top or frees a chunk (caching it for reuse).

The eval-frame function

CPython dispatches frame execution through a per-interpreter function pointer of type _PyFrameEvalFunction:

c
// Include/cpython/pystate.h
typedef PyObject* (*_PyFrameEvalFunction)(PyThreadState *tstate,
                                          struct _PyInterpreterFrame *, int);

PyAPI_FUNC(_PyFrameEvalFunction) _PyInterpreterState_GetEvalFrameFunc(
    PyInterpreterState *interp);
PyAPI_FUNC(void) _PyInterpreterState_SetEvalFrameFunc(
    PyInterpreterState *interp, _PyFrameEvalFunction eval_frame);

(The names use EvalFrameFunc colloquially; the C symbol is _PyInterpreterState_GetEvalFrameFunc / ..._SetEvalFrameFunc.) By default this points at _PyEval_EvalFrameDefault:

c
// Python/ceval.c
PyObject *_PyEval_EvalFrameDefault(PyThreadState *tstate,
                                   _PyInterpreterFrame *frame, int exc);

This indirection is what PEP 523 custom frame evaluators (debuggers, JITs, perf trampolines) plug into. perf_trampoline.c, for example, swaps the function to record/restore the previous one. The interpreter calls it as _PyEval_EvalFrame(tstate, frame, exc).

Frame initialization

When a Python function is called, _PyFrame_Initialize fills in the specials and zeroes the locals:

c
// Include/internal/pycore_interpframe.h
static inline void
_PyFrame_Initialize(PyThreadState *tstate, _PyInterpreterFrame *frame,
                    _PyStackRef func, PyObject *locals, PyCodeObject *code,
                    int null_locals_from, _PyInterpreterFrame *previous)
{
    frame->previous = previous;
    frame->f_funcobj = func;
    frame->f_executable = PyStackRef_FromPyObjectNew(code);
    PyFunctionObject *func_obj = (PyFunctionObject *)PyStackRef_AsPyObjectBorrow(func);
    frame->f_builtins = func_obj->func_builtins;
    frame->f_globals = func_obj->func_globals;
    frame->f_locals = locals;
    frame->stackpointer = frame->localsplus + code->co_nlocalsplus;
    frame->frame_obj = NULL;
    frame->instr_ptr = _PyCode_CODE(code);
    frame->return_offset = 0;
    frame->owner = FRAME_OWNED_BY_THREAD;
    for (int i = null_locals_from; i < code->co_nlocalsplus; i++) {
        frame->localsplus[i] = PyStackRef_NULL;
    }
}

Note f_globals/f_builtins are borrowed from the function object (cheaper than strong refs), while f_funcobj is a strong (stack) reference.

Locals and fast locals

A code object’s co_nlocalsplus counts fast locals, cell variables and free variables. They all live in the localsplus array starting at offset 0; the value stack begins at localsplus + co_nlocalsplus (see _PyFrame_Stackbase). Each slot’s kind is recorded in co_localspluskinds.

For optimized (function) frames, f_locals on the interpreter frame is usually NULL and f_locals exposed via PyFrameObject is a write-through proxy (FrameLocalsProxy), not a dict. That is why PyFrame_FastToLocals and PyFrame_LocalsToFast became no-ops in 3.15:

c
// Objects/frameobject.c
void PyFrame_FastToLocals(PyFrameObject *f) { return; }
void PyFrame_LocalsToFast(PyFrameObject *f, int clear) { return; }
int PyFrame_FastToLocalsWithError(PyFrameObject *f) { return 0; }

The proxy reads/writes slots directly:

c
// Objects/frameobject.c
static PyObject *framelocalsproxy_getval(_PyInterpreterFrame *frame,
                                         PyCodeObject *co, int i)
{
    _PyLocals_Kind kind = _PyLocals_GetKind(co->co_localspluskinds, i);
    if (kind & CO_FAST_LOCAL) {
        return PyStackRef_AsPyObjectBorrow(frame->localsplus[i]);
    }
    ...
}

For non-optimized code (module/class/exec scopes) f_locals is a real dict. frame_locals_get returns it, or creates an empty dict if it is NULL.

Overwriting a fast local through f_locals (e.g. a debugger sets frame.f_locals['x'] = ...) is handled by f_overwritten_fast_locals: a tuple of strong references that keep borrowed references in newer frames valid.

Generator and coroutine frames

Generators, coroutines and async generators embed an _PyInterpreterFrame directly in the object, so a generator is a single allocation:

c
// Include/internal/pycore_interpframe_structs.h
#define _PyGenObject_HEAD(prefix)                       \
    PyObject_HEAD                                       \
    ...                                                 \
    int8_t prefix##_frame_state;                        \
    _PyInterpreterFrame prefix##_iframe;

A generator frame has owner == FRAME_OWNED_BY_GENERATOR. Its execution state is tracked separately in gi_frame_state (FRAME_CREATED, FRAME_SUSPENDED, FRAME_EXECUTING, FRAME_CLEARED, …). gen_send_ex2 pushes the yielded argument onto gi_iframe’s value stack and calls _PyEval_EvalFrame:

c
// Objects/genobject.c
_PyFrame_StackPush(frame, PyStackRef_FromPyObjectNew(arg_obj));
...
PyObject *result = _PyEval_EvalFrame(tstate, frame, exc);

When a generator’s frame outlives the generator (e.g. a frame object is kept), _PyFrame_ClearExceptCode calls take_ownership, copying the embedded frame into the PyFrameObject’s _f_frame_data.

Materializing a PyFrameObject

_PyFrame_GetFrameObject lazily creates the heap frame and caches it in frame->frame_obj:

c
// Include/internal/pycore_interpframe.h
static inline PyFrameObject *
_PyFrame_GetFrameObject(_PyInterpreterFrame *frame)
{
    assert(!_PyFrame_IsIncomplete(frame));
    PyFrameObject *res = frame->frame_obj;
    if (res != NULL) {
        return res;
    }
    return _PyFrame_MakeAndSetFrameObject(frame);
}

_PyFrame_MakeAndSetFrameObject allocates a PyFrameObject (via _PyFrame_New_NoTrack), sets f->f_frame = frame, and records frame->frame_obj = f. The first time a frame becomes visible, take_ownership copies the interpreter frame into _f_frame_data so the heap object survives even after the stack frame is popped.

PyFrame_GetBack walks f_back, falling back to frame->previous when the back link has not been materialized yet.

Clearing a frame

_PyFrame_ClearExceptCode is used when a frame is done (or being cleared). It either takes ownership (if the frame object is still referenced) or clears locals and drops references:

c
// Python/frame.c
void _PyFrame_ClearExceptCode(_PyInterpreterFrame *frame)
{
    if (frame->frame_obj) {
        PyFrameObject *f = frame->frame_obj;
        frame->frame_obj = NULL;
        if (!_PyObject_IsUniquelyReferenced((PyObject *)f)) {
            take_ownership(f, frame);
            Py_DECREF(f);
            return;
        }
        Py_DECREF(f);
    }
    _PyFrame_ClearLocals(frame);
    PyStackRef_CLEAR(frame->f_funcobj);
}

_PyFrame_ClearLocals closes the value stack (popping and closing each _PyStackRef) and clears f_locals.

Key takeaways

  • A frame is two structs: _PyInterpreterFrame (the running frame, on the thread data stack or inside a generator) and PyFrameObject (the Python types.FrameType, created lazily).
  • Frames are allocated in per-thread chunks via _PyThreadState_PushFrame; the base frame is a sentinel.
  • Execution goes through _PyFrameEvalFunction, defaulting to _PyEval_EvalFrameDefault, swappable for profilers/JITs.
  • Fast locals live in localsplus; for optimized frames f_locals is a write-through proxy, making the old FastToLocals/LocalsToFast APIs no-ops.
  • Generator/coroutine frames are embedded in the generator object and tracked by gi_frame_state.
  • When a frame escapes, take_ownership copies the interpreter frame into the heap frame object.

Further reading

  • Python/frame.c – frame object materialization and ownership transfer.
  • Objects/frameobject.c – PyFrameObject, locals proxy.
  • Objects/genobject.c – generator/coroutine frame execution.
  • Python/pystate.c – frame stack chunks, push/pop.
  • Include/internal/pycore_interpframe.h and Include/internal/pycore_interpframe_structs.h – struct definitions.
  • InternalDocs/frames.md – design notes.

PythonCpythonInternalsFrameFrames