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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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) andPyFrameObject(the Pythontypes.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 framesf_localsis 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_ownershipcopies 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.handInclude/internal/pycore_interpframe_structs.h– struct definitions.InternalDocs/frames.md– design notes.