Lesson 07

Frames, Code Objects and the Eval Loop

Where Python code actually runs, what a frame is, and the single most consequential fact for extension authors: your C function does not get one.

Learning objective

You can describe what a code object and a frame are and how they relate, explain why C functions are invisible to the Python call stack, and know the specific obligations that follow — recursion guarding, signal handling, profiler visibility, and reentrancy across the C/Python boundary.

Code objects: the compiled unit

When CPython compiles a module, each function body, class body, comprehension and module top level becomes a code object — an immutable Python object of type code. It holds the compiled bytecode plus everything the bytecode refers to by index:

def f(a, b=2):
    x = a + b
    return x

c = f.__code__
print(c.co_name, c.co_argcount, c.co_varnames, c.co_consts, c.co_names)
import dis; dis.dis(c)

A code object is static: one per function definition, shared by every call. It contains no runtime state at all — no argument values, no local variable values, no instruction pointer. From C its type is PyCodeObject, and the accessors you may use are the public PyCode_* functions; its struct layout is explicitly unstable and changes between minor versions.

Frames: one call's worth of state

The runtime half is the frame. Calling a Python function creates a frame holding:

The chain of frames through the "calling frame" links is the Python call stack. It is what a traceback walks, what sys._getframe() returns, and what a debugger inspects.

Frames are not what they used to be

Before Python 3.11, every call heap-allocated a PyFrameObject. That was expensive, and most frames are never inspected. Since 3.11, the actual execution state lives in a compact, contiguous "data stack" chunk owned by the thread state, and the PyFrameObject you can touch from Python or C is materialized lazily — only if something asks for it (a traceback, sys._getframe, a debugger, a generator being suspended).

Practically: calls got much cheaper, and asking for a frame object is no longer free. Do not grab frames casually from C.

Touching frames from C, correctly

If you genuinely need the current frame — a profiler, a logging helper that reports the caller's file and line — use the public accessors, which all return strong references:

PyFrameObject *f = PyThreadState_GetFrame(PyThreadState_Get());  /* strong, may be NULL */
if (f != NULL) {
    PyCodeObject *code = PyFrame_GetCode(f);      /* strong, never NULL */
    int line = PyFrame_GetLineNumber(f);
    PyFrameObject *back = PyFrame_GetBack(f);     /* strong, may be NULL */
    ...
    Py_XDECREF(back);
    Py_DECREF(code);
    Py_DECREF(f);
}

The old style — reading tstate->frame and f->f_code directly — is broken on modern CPython and is exactly the kind of private-struct access that makes extensions fail to build on each new release.

The eval loop

Bytecode is executed by one very large C function, _PyEval_EvalFrameDefault, which is a dispatch loop over instructions. Its shape has changed a lot and will keep changing:

None of this is API you should touch. It matters to you for one reason: the interpreter's internals are a moving target, and everything in this course is about staying on the stable side of that line. The public C API survives these rewrites; _Py-prefixed symbols and struct fields do not.

The fact that matters: C functions have no frame

When Python calls a function implemented in C, the interpreter does not push a frame. It simply calls your C function on the existing C stack. Your code runs in the C world, invisible to the Python call stack.

  Python frames (visible to tracebacks)      C stack (real machine stack)
  ────────────────────────────────────      ───────────────────────────────
   <module>                                  _PyEval_EvalFrameDefault
      └─ main()                                   └─ ...
           └─ process()                                └─ your_c_function()   ← no frame
                  · calls your extension                    └─ PyObject_CallOneArg
                                                                 └─ _PyEval_EvalFrameDefault
   <──── callback() appears here ────>                              └─ callback()
Your C function sits between two Python frames without being one.

Five consequences follow directly, and each one is a real obligation.

1. You do not consume the Python recursion limit — so guard yourself

sys.setrecursionlimit counts Python frames. A recursive C function creates none, so nothing stops it from running the real machine stack into the guard page, which is a hard crash rather than a RecursionError. If your C code can recurse — a recursive tp_repr, a tree walker, a serializer — you must opt in to the check:

static PyObject *
serialize(PyObject *self, PyObject *obj)
{
    if (Py_EnterRecursiveCall(" while serializing")) {
        return NULL;              /* RecursionError already set */
    }
    PyObject *result = do_the_work(obj);   /* may call serialize() again */
    Py_LeaveRecursiveCall();
    return result;
}

Every Py_EnterRecursiveCall that returned zero must be matched by exactly one Py_LeaveRecursiveCall, on every path. Recent CPython versions additionally track remaining C-stack headroom separately from the Python recursion limit, precisely because C extensions and the interpreter itself consume the native stack at very different rates.

2. You are invisible in tracebacks

A traceback is built from frames. Your C function contributes no line to it — the deepest Python line shown will be the call site. This is why the advice in Lesson 05 to write specific, self-describing error messages is not cosmetic: the message is the only context the user gets.

3. Profilers see you through a side channel

sys.setprofile and the C-level PyEval_SetProfile emit explicit PyTrace_C_CALL, PyTrace_C_RETURN and PyTrace_C_EXCEPTION events for calls into C functions, because there is no frame to hook. sys.settrace — line tracing — has nothing to trace inside C and reports nothing. Python 3.12's sys.monitoring (PEP 669) is the modern, low-overhead version of the same idea.

This is why a C extension often shows up in a profile as one opaque block. If you want visibility inside it, you need native profiling tools, not Python ones.

4. Signals are only handled at interpreter safe points

Covered in Lesson 05, but it belongs to this model: the OS signal handler merely sets a flag; the Python-level handler runs in the eval loop between instructions. A C function that runs for ten seconds without returning has given the eval loop no opportunity. PyErr_CheckSignals() creates one.

5. Calling into Python re-enters the eval loop on your C stack

When your C function calls a Python callback, a fresh _PyEval_EvalFrameDefault runs on top of your stack frame. If that callback calls back into your extension, you get alternating C and Python layers. Two implications:

Generators and coroutines, briefly

A generator is a frame that can be suspended: its execution state is detached from the C stack and kept alive in the generator object, then resumed later. This is why generators exist in Python but are awkward to write in C — there is no C-level equivalent of suspending mid-function.

The practical answer for extension authors is not to try. Implement an iterator instead: a type with tp_iter returning itself and tp_iternext returning the next item, keeping any state you need in the instance struct. tp_iternext signals exhaustion by returning NULL with no exception set (as opposed to NULL with StopIteration set, which also works but is slower, and NULL with any other exception, which is a real error).

static PyObject *
Range_next(PyObject *op)
{
    RangeObject *self = (RangeObject *)op;
    if (self->i >= self->stop) {
        return NULL;              /* exhausted: no exception set */
    }
    return PyLong_FromLong(self->i++);
}

Frame evaluation hooks

For completeness, because you will see it referenced: PEP 523 lets a C extension replace the function that evaluates frames, per interpreter, via _PyInterpreterState_SetEvalFrameFunc. That is the mechanism behind debuggers like pydevd and JITs like Pyjion and parts of PyTorch's compiler. It is private, unstable, and not something to reach for; it is mentioned here so you recognize it as a specialist tool rather than a general one.

Hands-on
import sys, dis

def outer():
    return inner()

def inner():
    f = sys._getframe()
    names = []
    while f is not None:
        names.append(f.f_code.co_name)
        f = f.f_back
    return names

print(outer())          # the Python frame chain

# Now watch a C function fail to appear in it:
print(list(map(lambda _: inner(), [0])))   # map is C; note what is listed

# Profilers see C calls through a separate event type:
def prof(frame, event, arg):
    if event.startswith("c_"):
        print(event, getattr(arg, "__name__", arg))
sys.setprofile(prof)
len([1, 2, 3]); abs(-1)
sys.setprofile(None)

# And the recursion limit counts Python frames only:
print(sys.getrecursionlimit())
dis.dis(outer)

Then answer: if sorted(data, key=my_key) is called with a my_key written in Python, how many times does control cross the C/Python boundary, and what does that predict about the cost of a C sort with a Python key function?

Further reading