Generator and Coroutine Frame Internals

An ordinary Python function call gets an activation record — a frame — that is bump-allocated on the calling thread’s data stack and torn down the instant the call returns. A generator or coroutine cannot work that way: its frame must survive the call that created it, because the whole point is to pause the function and resume it later. CPython solves this by embedding the _PyInterpreterFrame directly inside the generator/coroutine object (gi_iframe), so the frame is heap-allocated as part of the object and lives exactly as long as the object does. Suspension then costs almost nothing: there is no copying, no separate allocation, no stack juggling — pausing is a matter of unlinking one pointer (frame->previous) and flipping a state byte, and resuming is relinking it. This note covers the frame mechanics that make suspension cheap; the generator-level behavior built on top (yield, send, throw, close) lives in its sibling Generators and the yield Mechanism.

This is verified against the CPython v3.14.5 source tag (as of 10 May 2026) and rests on the frame redesign of CPython 3.11 (“zero-overhead frames”, part of the Faster CPython project) plus the frame-evaluation hook of PEP 523 (Python 3.6), whose signature was itself changed by the 3.11 redesign. The authoritative internal description is CPython’s own InternalDocs/frames.md.

Mental Model

Picture two kinds of frame storage. A normal call’s frame lives in a contiguous, per-thread data stack (_PyThreadState_PushFrame): cheap to allocate (just bump a pointer), great for cache locality, but it is stack-disciplined — it must be popped before the caller’s frame, so it cannot outlive the call. A generator’s frame instead lives inside the generator object on the heap. When the generator runs, its embedded frame is temporarily linked into the live call chain via a previous pointer; when it yields, that link is severed and the frame stays put inside the object, fully populated, until the next resume re-links it.

flowchart TB
    subgraph TD["Thread data stack (bump-allocated, LIFO)"]
        direction TB
        main["main() frame"]
        caller["caller() frame<br/>(currently executing)"]
        main --> caller
    end
    subgraph HEAP["Heap: PyGenObject"]
        direction TB
        head["gi_name, gi_qualname,<br/>gi_exc_state, gi_frame_state"]
        iframe["gi_iframe : _PyInterpreterFrame<br/>owner = FRAME_OWNED_BY_GENERATOR<br/>locals + eval stack + instr_ptr"]
        head --- iframe
    end
    caller -. "on resume: gen_frame->previous = caller<br/>(SEND/send links it in)" .-> iframe
    iframe -. "on yield: previous = NULL<br/>(frame stays, object owns it)" .-> caller

Diagram: the generator’s frame is embedded in the heap object (gi_iframe), not on the thread’s data stack. While running, its previous pointer links it onto the live call chain just below the caller; on yield the link is cut and the populated frame remains inside the object. The insight: suspension is pointer relinking, not copying — which is why generators are cheap to pause and resume, and why a million suspended generators cost a million frame-sized heap objects but no stack space.

Why Frames Must Outlive the Call

Frames cannot generally be freed on return because Python semantics let a frame outlive its activation: a traceback keeps frames alive after an exception unwinds, sys._getframe() exposes a live frame to Python, and — the case that matters here — a generator pauses mid-execution and resumes later. CPython’s frames.md states it directly: “Python semantics allows frames to outlive the activation, so they need to be allocated outside the C call stack.” For most frames the fix is the per-thread data stack (contiguous, fast, popped on return). But “frames of generators and coroutines are embedded in the generator and coroutine objects, so are not allocated in the per-thread stack” (frames.md). Embedding means a generator costs exactly one heap allocation — the object and its frame together — rather than an object plus a separately-allocated frame, which is both faster to create and better for locality.

This is precisely where a generator frame differs from the on-stack _PyInterpreterFrame described in Stack Frames and the Frame Stack: that note covers the thread-datastack frame that normal calls use; this note covers the generator-owned frame, which is the same struct type but lives in a different place and is freed by a different mechanism. The struct is shared; the ownership and storage differ — and the owner field is exactly how the runtime tells them apart.

The _PyInterpreterFrame Struct and the owner Field

The frame struct, from pycore_interpframe_structs.h:

struct _PyInterpreterFrame {
    _PyStackRef f_executable;          // the code object (or None) being run
    struct _PyInterpreterFrame *previous; // link to caller's frame; NULL when suspended
    _PyStackRef f_funcobj;             // the function object
    PyObject *f_globals;               // borrowed: module globals
    PyObject *f_builtins;              // borrowed: builtins
    PyObject *f_locals;                // strong, may be NULL: locals dict for eval/class
    PyFrameObject *frame_obj;          // strong, may be NULL: the Python-visible wrapper
    _Py_CODEUNIT *instr_ptr;           // the bytecode instruction to resume at
    _PyStackRef *stackpointer;         // top of this frame's evaluation stack
#ifdef Py_GIL_DISABLED
    int32_t tlbc_index;                // thread-local bytecode index (free-threaded build)
#endif
    uint16_t return_offset;            // where a RETURN lands in the caller
    char owner;                        // <-- WHO OWNS / WHERE THIS FRAME LIVES
    ...
    _PyStackRef localsplus[1];         // flexible array: locals THEN eval stack
};

The discriminator is owner, an enum in the same header:

enum _frameowner {
    FRAME_OWNED_BY_THREAD       = 0,  // bump-allocated on the per-thread data stack
    FRAME_OWNED_BY_GENERATOR    = 1,  // embedded in a gen/coro/async-gen object
    FRAME_OWNED_BY_FRAME_OBJECT = 2,  // copied into a heap PyFrameObject (see below)
    FRAME_OWNED_BY_INTERPRETER  = 3,  // the special shim/entry frame
    FRAME_OWNED_BY_CSTACK       = 4,  // a trampoline frame on the C stack
};

A generator’s embedded frame always has owner == FRAME_OWNED_BY_GENERATOR. This single byte is how every piece of the runtime — the evaluator, the garbage collector, the frame-teardown code — answers the question “when this frame is finished, who is responsible for its memory, and where does it live?” For a FRAME_OWNED_BY_THREAD frame, the answer is “pop it off the data stack.” For FRAME_OWNED_BY_GENERATOR, the answer is “do nothing to the storage — it belongs to the enclosing object, which the garbage collector will free when the generator dies.” That is why YIELD_VALUE can leave the frame fully populated and simply walk away: the storage is not on a stack that is about to be reclaimed.

owner vs. gi_frame_state — two different questions

It is easy to confuse the frame’s owner byte with the generator’s gi_frame_state byte, because both live in the same object and both are one byte. They answer orthogonal questions. gi_frame_state (the PyFrameState enum: FRAME_CREATED/FRAME_SUSPENDED/FRAME_EXECUTING/FRAME_COMPLETED/FRAME_CLEARED, plus FRAME_SUSPENDED_YIELD_FROM) is the lifecycle — “where in its run is this generator right now?” — and is the subject of Generators and the yield Mechanism. owner is storage/ownership — “where do these frame bytes live and who frees them?” — and is essentially constant (FRAME_OWNED_BY_GENERATOR) for the life of a generator’s frame, changing only if the frame’s contents are copied out into a standalone PyFrameObject (the take_ownership path below). A generator can move through every gi_frame_state while owner never changes; conversely a normal stack frame has an owner of FRAME_OWNED_BY_THREAD and no gi_frame_state at all.

Recovering the Object from the Frame

Because the frame is embedded at a fixed offset inside the generator object, the runtime can do the reverse lookup — given a frame pointer, find its owning generator — with pointer arithmetic, no extra field needed. From pycore_genobject.h:

static inline PyGenObject *
_PyGen_GetGeneratorFromFrame(_PyInterpreterFrame *frame) {
    assert(frame->owner == FRAME_OWNED_BY_GENERATOR);
    size_t offset_in_gen = offsetof(PyGenObject, gi_iframe);
    return (PyGenObject *)(((char *)frame) - offset_in_gen);
}

The function asserts owner == FRAME_OWNED_BY_GENERATOR (the lookup is only valid for embedded frames), then subtracts the compile-time offsetof(PyGenObject, gi_iframe) from the frame address to land on the object header. This is exactly the call YIELD_VALUE makes to recover the generator and set its gi_frame_state — the embedding is what makes that O(1) and allocation-free.

Creation: Allocate Empty, Then Copy the Frame In

Two phases create a generator’s frame, and conflating them is a common error. Phase one is allocation only, in make_gen (genobject.c):

static PyObject * make_gen(PyTypeObject *type, PyFunctionObject *func) {
    PyCodeObject *code = (PyCodeObject *)func->func_code;
    int slots = _PyFrame_NumSlotsForCodeObject(code);     // locals + max stack depth
    PyGenObject *gen = PyObject_GC_NewVar(PyGenObject, type, slots);
    ...
    gen->gi_frame_state = FRAME_CLEARED;                  // not yet a live frame
    gen->gi_iframe.f_executable = PyStackRef_None;        // empty placeholder
    gen->gi_name = Py_NewRef(func->func_name);
    gen->gi_qualname = Py_NewRef(func->func_qualname);
    _PyObject_GC_TRACK(gen);
    return (PyObject *)gen;
}

PyObject_GC_NewVar(PyGenObject, type, slots) is the key: the generator is a variable-length object whose trailing slots are the frame’s localsplus[] array. The count comes from _PyFrame_NumSlotsForCodeObject, which is code->co_framesize - FRAME_SPECIALS_SIZE (pycore_interpframe.h) — i.e. enough room for all the function’s locals/cells/free variables plus its maximum evaluation-stack depth. The object and its entire frame are thus one contiguous allocation. At this point the frame is empty: gi_frame_state == FRAME_CLEARED, f_executable == None.

Phase two copies the live frame in, and it happens later, in the RETURN_GENERATOR opcode (bytecodes.c) — the first real instruction of any generator’s body, executed when the function is called:

inst(RETURN_GENERATOR, (-- res)) {
    PyFunctionObject *func = (PyFunctionObject *)PyStackRef_AsPyObjectBorrow(frame->f_funcobj);
    PyGenObject *gen = (PyGenObject *)_Py_MakeCoro(func);   // -> make_gen, allocates
    assert(STACK_LEVEL() == 0);
    SAVE_STACK();
    _PyInterpreterFrame *gen_frame = &gen->gi_iframe;
    frame->instr_ptr++;
    _PyFrame_Copy(frame, gen_frame);                        // copy current frame -> embedded
    gen->gi_frame_state = FRAME_CREATED;                    // now a real, ready frame
    gen_frame->owner = FRAME_OWNED_BY_GENERATOR;            // mark ownership
    _Py_LeaveRecursiveCallPy(tstate);
    _PyInterpreterFrame *prev = frame->previous;
    _PyThreadState_PopFrame(tstate, frame);                 // discard the stack frame
    frame = tstate->current_frame = prev;
    LOAD_IP(frame->return_offset);
    res = PyStackRef_FromPyObjectStealMortal((PyObject *)gen); // return the generator
}

The sequence: when you call a generator function, the interpreter sets up a normal stack frame (with the arguments already bound to locals) and starts executing — but the very first instruction, RETURN_GENERATOR, builds the generator object, copies the freshly-set-up stack frame into the object’s embedded gi_iframe with _PyFrame_Copy, marks it FRAME_OWNED_BY_GENERATOR and FRAME_CREATED, then pops and discards the temporary stack frame and returns the generator object to the caller. So the body has not really run; only its frame setup (argument binding) has, and that setup now lives inside the generator. _PyFrame_Copy (pycore_interpframe.h) copies the specials and the localsplus slots and pointedly sets dest->previous = NULL with the comment “Don’t leave a dangling pointer to the old frame when creating generators and coroutines” — the embedded frame starts life unlinked.

Note

There is also a legacy C-API creation path, PyGen_New/gen_new_with_qualname, used when a generator is built from an existing heap PyFrameObject (e.g. via the C API). It likewise calls _PyFrame_Copy from f->_f_frame_data into gi_iframe, sets owner = FRAME_OWNED_BY_GENERATOR, and re-points f->f_frame at the embedded copy. The modern in-interpreter path is RETURN_GENERATOR → _Py_MakeCoro → make_gen; treat gen_new_with_qualname as the legacy route.

_Py_MakeCoro is the dispatcher that decides which object to build from the code flags: CO_GENERATOR alone → PyGen_Type; CO_ASYNC_GENERATORPyAsyncGen_Type; CO_COROUTINEPyCoro_Type. All three share the embedded-frame layout (_PyGenObject_HEAD), so coroutine and async-generator frames work identically to generator frames at this level — the difference is entirely in the higher-level protocol (Coroutines and the async await Protocol).

Suspend and Resume: Relinking previous

Now the payoff. Resuming a suspended generator must splice its embedded frame back into the live call chain. The SEND opcode’s fast path (_SEND_GEN_FRAME, bytecodes.c) does exactly this when one generator drives another (as in yield from/await):

op(_SEND_GEN_FRAME, (receiver, v -- receiver, gen_frame: _PyInterpreterFrame *)) {
    PyGenObject *gen = (PyGenObject *)PyStackRef_AsPyObjectBorrow(receiver);
    DEOPT_IF(gen->gi_frame_state >= FRAME_EXECUTING);   // must be CREATED or SUSPENDED
    gen_frame = &gen->gi_iframe;
    _PyFrame_StackPush(gen_frame, PyStackRef_MakeHeapSafe(v)); // sent value -> its stack
    gen->gi_frame_state = FRAME_EXECUTING;
    gen->gi_exc_state.previous_item = tstate->exc_info; // swap exception state in
    tstate->exc_info = &gen->gi_exc_state;
    frame->return_offset = (uint16_t)(INSTRUCTION_SIZE + oparg);
    gen_frame->previous = frame;                         // <-- LINK: gen frame below caller
}

The decisive line is gen_frame->previous = frame: the generator’s embedded frame is linked below the currently-running frame, becoming the new top of the call chain so the evaluator descends into it (_PUSH_FRAME follows in the SEND_GEN macro). The sent value v is pushed onto the generator’s own value stack, which is how value = yield x receives it. The thread’s exc_info is swapped to the generator’s saved slot.

Suspending (the YIELD_VALUE opcode) is the exact inverse — it severs the link:

inst(YIELD_VALUE, (retval -- value)) {
    frame->instr_ptr++;
    PyGenObject *gen = _PyGen_GetGeneratorFromFrame(frame);
    gen->gi_frame_state = FRAME_SUSPENDED + oparg;       // -2 plain, -1 yield-from
    SAVE_STACK();                                        // persist eval stack in the frame
    tstate->exc_info = gen->gi_exc_state.previous_item;  // restore caller's exc_info
    gen->gi_exc_state.previous_item = NULL;
    _PyInterpreterFrame *gen_frame = frame;
    frame = tstate->current_frame = frame->previous;     // <-- pop back to caller
    gen_frame->previous = NULL;                          // <-- UNLINK: frame survives alone
    ...
}

frame = tstate->current_frame = frame->previous makes the caller the current frame again, and gen_frame->previous = NULL cuts the generator frame loose. No bytes move; the frame simply stops being part of the live chain while remaining intact inside its object. This unlink/relink pair — done by flipping one pointer and one state byte each way — is the reason generators are cheap to suspend and resume. There is no allocation, no memcpy of the frame, no stack unwinding. Contrast a thread-based pause, which must park an entire OS thread; here, “pausing” is two pointer writes. (SAVE_STACK()/RELOAD_STACK() synchronize the C-local cached stack pointer with the frame’s stackpointer field across the boundary; the value stack itself stays in localsplus[] the whole time.)

The frame->return_offset set during SEND is the bookkeeping that tells the suspended generator where to send a RETURN versus a YIELD in the driving frame — frames.md notes SEND “needs to pass two offsets to the generator: one for RETURN and one for YIELD. It uses the oparg for one, and the return_offset for the other,” which is how a delegated subgenerator’s eventual return lands correctly back in the yield from site.

When the Frame Object Outlives the Generator: take_ownership

A wrinkle: Python code can grab a generator’s frame as a first-class PyFrameObject (via gen.gi_frame or a traceback). The embedded _PyInterpreterFrame is not a PyFrameObject; the latter is a lazily-created wrapper whose f_frame points at the embedded frame. So what happens if the PyFrameObject is still referenced after the generator is destroyed? The frame contents must be copied out of the dying generator into the standalone PyFrameObject, which then owns them. This is take_ownership in frame.c:

static void take_ownership(PyFrameObject *f, _PyInterpreterFrame *frame) {
    _PyInterpreterFrame *new_frame = (_PyInterpreterFrame *)f->_f_frame_data;
    _PyFrame_Copy(frame, new_frame);                       // copy embedded -> PyFrameObject
    new_frame->f_executable = PyStackRef_DUP(new_frame->f_executable);
    f->f_frame = new_frame;
    new_frame->owner = FRAME_OWNED_BY_FRAME_OBJECT;        // ownership transferred
    ...
    _PyInterpreterFrame *prev = _PyFrame_GetFirstComplete(frame->previous);
    if (prev) {
        PyFrameObject *back = _PyFrame_GetFrameObject(prev);
        f->f_back = (PyFrameObject *)Py_NewRef(back);      // relink f_back chain
    }
    ...
}

This is invoked from _PyFrame_ClearExceptCode when a frame is being torn down but its frame_obj is not uniquely referenced (someone else holds the PyFrameObject). The contents are copied into the PyFrameObject’s inline _f_frame_data storage, owner flips to FRAME_OWNED_BY_FRAME_OBJECT, and — note the last block — the previous linkage (a _PyInterpreterFrame* chain) is converted into the Python-visible f_back linkage (a PyFrameObject* chain), keeping tracebacks coherent. frames.md summarizes: “If a frame object associated with a generator outlives the generator, then the embedded _PyInterpreterFrame is copied into the frame object (see take_ownership()).” This is the only time a generator frame is copied after creation; the common suspend/resume path never copies.

PEP 523 and the Frame-Evaluation Hook

The frame that a generator embeds is run by the same evaluator as any other frame, reachable through the PEP 523 hook. PEP 523 (“Adding a frame evaluation API to CPython”, Python 3.6) added a per-interpreter function pointer, eval_frame, so tools — debuggers, profilers, and JITs — can intercept frame execution (PEP 523). The PEP’s original (3.6) signature was PyObject* (*)(PyFrameObject*, int), but the Python 3.11 frame redesign changed it to take a PyThreadState* and operate on the lighter internal _PyInterpreterFrame instead of a PyFrameObject — a porting break documented in the 3.11 C-API notes (bpo-46355) and felt by every PEP 523 consumer, since the new frame pointer is opaque (profilers that used to read the line number off the PyFrameObject had to switch to the unstable PyUnstable_InterpreterFrame_GetLine API). As of 3.14.5 the type in pystate.h is:

typedef PyObject* (*_PyFrameEvalFunction)(PyThreadState *tstate,
                                          struct _PyInterpreterFrame *, int);

so a custom evaluator receives the very _PyInterpreterFrame — whether thread-owned or generator-owned — and the throwflag. This matters for generators because the same hook fires whether the frame came from a normal call or from gen_send_ex2 resuming an embedded frame; the embedding is transparent to the evaluator. PyTorch’s Dynamo, the Pyjion JIT, and debuggers all sit on this hook by replacing the frame evaluator wholesale. (The in-tree CPython JIT, PEP 744, does not use this hook — it dispatches from inside the normal evaluation loop via the tier-2 ENTER_EXECUTOR micro-op path, not by swapping eval_frame.) The public accessors are _PyInterpreterState_SetEvalFrameFunc / _PyInterpreterState_GetEvalFrameFunc (pystate.h).

Failure Modes and Common Misunderstandings

“The generator’s frame is on the stack.” No — only its previous link is transiently on the live chain while running. The frame storage is always inside the heap object. This is why a generator can be resumed from a different call stack than the one that created it.

gen.gi_frame and the internal frame are the same object.” They are not. gi_frame returns a PyFrameObject wrapper, lazily created on first access (_gen_getframe); the embedded frame is a _PyInterpreterFrame. Accessing gi_frame materializes the wrapper, and if it outlives the generator, take_ownership copies the contents out.

Confusing owner with gi_frame_state. Re-stated because it is the single most common conflation: owner is where the bytes live (constant for a generator), gi_frame_state is the lifecycle (changes on every advance). See Generators and the yield Mechanism for the full gi_frame_state machine.

Per-generator memory cost. Because each generator embeds a full frame sized for the function’s locals + maximum stack depth, holding many live generators costs that much heap each. A program with millions of suspended coroutines (common in high-concurrency asyncio servers) pays proportionally; this is the real, measurable cost of the cheap-suspension design.

Alternatives and Design Comparison

The embedded-frame design is a deliberate trade against two alternatives. Separately-allocated frames (a generator object plus a heap-allocated frame it points at) would cost two allocations per generator and worse locality — rejected for the single-allocation win. Copying the frame onto and off the stack at each suspend/resume (the conceptually simplest model) would make pausing O(frame size) instead of O(1) — rejected because suspend/resume is the hot path. The chosen design — embed once, relink a pointer to suspend/resume, copy out only in the rare take_ownership case — optimizes the common path at the cost of a fixed per-object frame footprint. This is the same _PyInterpreterFrame struct that normal calls use on the thread data stack (Stack Frames and the Frame Stack); the generosity of CPython 3.11’s redesign was making one frame representation serve both stack-allocated calls and heap-embedded generators, distinguished only by the owner byte.

Production Notes

The embedded-frame model is what makes asyncio viable at scale: a coroutine is PyCoro_Type with the identical embedded cr_iframe, and an event loop holds thousands of suspended coroutines, each paused by the same two-pointer-write YIELD_VALUE/SEND dance (Coroutines and the async await Protocol, The asyncio Event Loop). Profilers and py-spy-style samplers walk the previous/f_back chains to reconstruct stacks, which is why the take_ownership relinking of f_back matters for accurate async tracebacks. The 3.11 frame redesign that introduced this layout was a headline part of the Faster CPython project; the cheap-frame design is also a prerequisite for the JIT, which operates over the same _PyInterpreterFrame representation (though it dispatches through the tier-2 executor path, not the PEP 523 hook).

See Also