Deep Engineering
Expert·Published·3.12 · 3.13 · 3.14·15 MIN

The interpreter loop: where a frame lives and how control is dispatched

The bytecode is ready — who runs it? One C loop, where a call frame is not a separate heap object but a chunk on the thread's data stack, and the next instruction is chosen in one of three ways depending on how Python was built. From this come both the recursion limit and the fact that `f_locals` stopped being a snapshot in 3.13.

Full technical treatment

TL;DR

The bytecode from the previous lesson needs someone to run it. That is one C loop_PyEval_EvalFrameDefault in Python/ceval.c: a giant switch over opcodes. It is one loop for all calls, not one loop per function.

A frame is not a heap object. Locals, the value stack, and bookkeeping live in a compact _PyInterpreterFrame struct laid as a chunk onto the thread's data stack, not malloc'd per call. The frame object a traceback sees is built lazily on top of it.

The dispatcher comes in three kinds. A plain switch, a computed goto (jump straight to the next opcode's code, skipping the loop head), and — since 3.14 — an experimental tail-call interpreter of small C functions. Which one is decided by the build.

Version-bound figures. HAVE_ARGUMENT: 90 → 44 → 43 (3.12 → 3.13 → 3.14). Loading a local: LOAD_FASTLOAD_FAST_BORROW in 3.14. A function's f_locals: snapshot dictwrite-through FrameLocalsProxy since 3.13 (PEP 667).

One loop for all calls

After compilation (the previous lesson) we have a code object with bytecode. Running it is not "a virtual machine" in the abstract but a concrete C function — _PyEval_EvalFrameDefault in Python/ceval.c. Inside it is a loop: fetch the next opcode, do what it says, move to the next.

The important part is that this loop is one. When a Python function calls another Python function, the C stack does not deepen by one _PyEval_EvalFrameDefault frame after another (it used to, long ago). Instead the same loop unrolls a new frame and keeps spinning. Hence a practical consequence: Python's recursion limit (sys.getrecursionlimit(), 1000 by default) is not a C-stack limit but a separate counter of Python frames.

The loop is not set in stone either. PEP 523 (3.6) introduced _PyFrameEvalFunction — a pointer to the frame-evaluation function that can be swapped. This is the hook debuggers and JITs attach to: they install their own function instead of the standard one. So the "interpreter loop" is an extension point, not a constant.

A frame does not live in the heap

The intuition "every call creates a frame object in the heap" is wrong, and it changes the whole cost picture of a call. Execution data — local variables, the value stack, a reference to the code object, a pointer to the previous frame — lives in the _PyInterpreterFrame struct (Include/internal/pycore_frame.h). It is laid as a chunk onto the thread's data stack: on a call the pointer simply moves forward, on return it moves back. No malloc, no garbage collection per call.

The full frame object (types.FrameType, the one seen in a traceback and in sys._getframe()) is an overlay. It is built lazily, only if someone actually asks for the frame as an object. An ordinary call that nobody introspects does not pay for it.

Hence both the recursion limit as a separate counter, and the fact that "Python is slow because of calls" is only half true: unrolling the frame itself is cheap; what is expensive is that every opcode goes through the dispatcher.

How the next instruction is chosen

The heart of the loop is the dispatcher: the code that, given an opcode number, hands control to its handling. In CPython it comes in three kinds, and which one is built depends on the compiler and build flags.

A plain switch. The portable variant: one switch (opcode) for all cases. After each opcode control returns to the loop head and re-enters the switch. Works everywhere, but an extra jump on every instruction.

computed goto. If the compiler supports labels-as-values (GCC, Clang), CPython builds a jump table and after an opcode jumps straight to the next one's code, bypassing the loop head. Fewer jumps and better branch prediction in the CPU — which is why this variant is the default where it is available.

Tail-call interpreter (3.14, experimental). The third variant: each opcode is a separate small C function, and the move to the next opcode is a tail call of such a function. Here is the honest boundary of the topic: there is neither a PEP nor InternalDocs for it — only the What's New 3.14 section, which calls it "a new type of interpreter" and gives the one number, "a geometric mean of 3-5% faster on the standard pyperformance benchmark suite", against a baseline of "Python 3.14 built with Clang 19, without this new interpreter". It cannot be reproduced by hand without a rebuild: the figure comes from What's New, not from a measurement of ours.

What all three share: from Python they are indistinguishable — only the speed of dispatch changes, not the behaviour of the opcodes.

The opcode set moves

Since dispatch is a lookup by opcode number, it is worth knowing that the opcode set itself changes from version to version, and hard-coding specific numbers is not safe. Two figures are visible by running bench/interpreter-loop/frames.py:

                   3.12   3.13   3.14
HAVE_ARGUMENT       90     44     43
named opcodes       140    150    238

HAVE_ARGUMENT is the boundary below which opcodes take no argument; it shifts (tools like dis take it from opcode rather than hard-coding it). The count of named opcodes grows through specialized forms — the ones the adaptive interpreter deals with (next lesson). In 3.14 there are noticeably more.

The changes show on a single line of code. Here is def one(a): return a + 1 across versions:

3.12 / 3.13:  RESUME  LOAD_FAST         LOAD_CONST      BINARY_OP  RETURN_VALUE
3.14:         RESUME  LOAD_FAST_BORROW  LOAD_SMALL_INT  BINARY_OP  RETURN_VALUE

LOAD_FAST in 3.14 became LOAD_FAST_BORROW — loading a local no longer touches the reference count where the value lives in the frame anyway and will not go away before the instruction ends. And LOAD_CONST for small integers became a dedicated LOAD_SMALL_INT. Both changes are about doing less work on the most frequent operation.

f_locals is no longer a snapshot

The most externally visible change to the loop over recent versions is the behaviour of f_locals. It used to hand back a snapshot of a frame's locals as a dict: a copy, writes to which never reached the frame. PEP 667 (3.13) changed that.

Verified by running it:

PYTHON
def g():
    x = 1
    loc = sys._getframe().f_locals
    loc["x"] = 99      # write into f_locals
    return x
  • Before 3.13: g() returns 1. f_locals was a snapshot dict; a write to it did not affect the real local.
  • Since 3.13: g() returns 99. f_locals is now a FrameLocalsProxywrite-through: the write reaches the frame itself.

This is exactly what PEP 667 was for: so that debuggers and tools that change locals through f_locals actually change them, instead of quietly writing to a copy. The object's type differs too: dict before 3.13, FrameLocalsProxy since.

Where the laziness ends

The frame's laziness described above is not absolute. It has a precise condition, and the condition is worth knowing, because it is broken by tools people switch on in production.

First, confirmation of the laziness itself, by identity rather than by argument (bench/interpreter-loop/frame_materialization.py):

1. Materialisation on demand
   type returned by sys._getframe()         : frame
   two calls in one frame — one object      : True
   sys._getframe(1) is _getframe().f_back   : True

(Labels translated from the script's output.) The object is built on demand and reused — the second sys._getframe() fetches the one already there. The price shows it too (3.13.7):

2. an empty call                                    19.4 ns
   + the first sys._getframe() (materialisation)    38.4
   + the second sys._getframe() (already there)      7.9

The first line after "an empty call" is the price of the superstructure, and it is paid once per frame. The second line must not be read as a number: it is the difference of two noisy measurements, and across repeated runs it wanders from negative values to about fifteen nanoseconds. All it shows is that the second access is substantially cheaper than the first.

Now the condition. sys.setprofile and sys.settrace receive a frame as the first argument of every event. So the object has to exist on every call, and nothing about it stays lazy:

3. no hooks                            17.8 ns   ×1.0
   sys.setprofile                     187.8      ×10.6
   sys.settrace (call only)           181.3      ×10.2
   sys.settrace (+ per line)          262.0      ×14.7
   calls to target(): 5, distinct frame objects seen by the profiler: 5

Five calls, five distinct frame objects: no reuse, because the frames differ. The tenfold price of a call is what "laziness switched off" means, and it explains why profiling in production changes not only the absolute numbers but the ratios: what gets more expensive is the call, not the work.

The multipliers hold on all three versions, but not in one band: on 3.13 and 3.14 it is roughly ×10 to ×15, on 3.12 noticeably more — per-line tracing reached about ×22 there. Per-line tracing costs more than per-call everywhere: 262.0 against 181.3 ns on 3.13.

The second way to materialise a chain is an exception. At depth 5 + 1 the traceback has seven links, and each holds a frame object: hence both the cost of a deep traceback and the reason a caught and stored exception keeps the whole chain of frames alive.

And a third case, useful in practice: a generator's gi_frame is that same frame object, two accesses give the same one, and after exhaustion it is None. So gi_frame tells you whether a generator is still alive or has finished.

A caveat about method: counting materialised frames through gc.get_objects() is not possible — since 3.11 frames do not sit in the collector's generation lists, and the count is zero both with hooks and without. The measurement observes the object's appearance, its identity and its price.

Instrumentation and the JIT sit on the same machinery

Two mechanisms are built right on top of the loop and are therefore cheap while off.

sys.monitoring (PEP 669, 3.12). Monitoring of events (call, return, line, branch) reuses the same instruction swap as specialization: while no event is of interest, there is nothing extra in the bytecode. Hence "low impact monitoring" in the PEP's name.

The JIT (PEP 744, status Draft). Shipping in 3.13+ is an experimental copy-and-patch JIT: it is built optionally and kicks in only for the hot regions the adaptive interpreter has already marked. The PEP's status is Draft, not Final: this is a direction, not a final language guarantee. Whether a region has an executor is visible from Python via _opcode.get_executor().

What to take away

The interpreter is one C function with a loop, shared across all frames. A new Python call does not deepen the C stack by an interpreter frame; it unrolls a frame in the same loop. Hence the recursion limit is a separate counter, not the C-stack limit.

A frame is a chunk on the thread's data stack, not a heap object. The frame object for tracebacks is overlaid lazily. Unrolling a frame is cheap; going through the dispatcher on every opcode is what costs.

The dispatcher comes in three kinds, and that is a build property. switch, computed goto, and since 3.14 the experimental tail-call one. From Python they are indistinguishable.

The opcode set and frame behaviour are version-bound. HAVE_ARGUMENT shifts, opcodes multiply, LOAD_FASTLOAD_FAST_BORROW, and f_locals went from a snapshot to a write-through proxy. Do not hard-code opcode numbers; do not read f_locals as a snapshot.

Common misconceptions

Claim

“Every function call creates a frame object in the heap.”

Actually

A call's data lives in the _PyInterpreterFrame struct as a chunk on the thread's data stack: on a call the pointer moves forward, on return it moves back. The full frame object (types.FrameType) is built lazily, only if actually requested. An ordinary call nobody introspects pays for no heap object.

Claim

“The recursion limit is the C system-stack limit.”

Actually

No: the interpreter loop is one for all calls and does not deepen the C stack by a frame on each Python call. sys.getrecursionlimit() (1000 by default) is a separate counter of Python frames, which exists precisely because the C stack cannot track this.

Claim

“The interpreter loop is an immovable CPython constant.”

Actually

PEP 523 (3.6) introduced _PyFrameEvalFunction — the loop can be swapped for your own; this is how debuggers and JITs attach. Moreover the dispatcher itself is built in one of three kinds (switch, computed goto, tail-call since 3.14) depending on the compiler and flags.

Claim

“f_locals is the locals dict, and you can write to it.”

Actually

Before 3.13 it was a snapshot: a write did not reach the frame (x stayed the same). Since 3.13 (PEP 667) it is a FrameLocalsProxy — write-through: a write changes the real local. Verified by running it: loc["x"]=99 gives 1 before 3.13 and 99 since 3.13.

Claim

“Opcode numbers are stable, you can hard-code them.”

Actually

The opcode set changes every version: HAVE_ARGUMENT — 90 → 44 → 43 (3.12 → 3.13 → 3.14), and the count of named opcodes grows (140 → 150 → 238) through specialized forms. Tools take the boundary from the opcode module rather than hard-coding it.

Knowledge check

Question 1 of 4

A Python function calls another Python function. What happens to the interpreter's C stack?

Sources & further reading

8 SOURCES

  1. CPython — Python/ceval.c, the _PyEval_EvalFrameDefault functionCPython source code. The interpreter loop itself: one giant switch over opcodes that runs a frame's bytecode. Hence the article's fact — one interpreter for all frames, not one loop per call; calling a Python function does not recursively call a C function but unrolls a new frame in the same loop. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/ceval.c
  2. CPython — Include/internal/pycore_frame.h, the _PyInterpreterFrame structCPython source code. The definition of an execution frame. Key for the article: it is a compact struct laid onto the thread's data stack in chunks, not a full heap object per call. The frame object (types.FrameType) that a traceback sees is built lazily on top of it. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Include/internal/pycore_frame.h
  3. PEP 523 — Adding a frame evaluation API to CPythonPEP. Dino Viehland, Brett Cannon; Final, Python 3.6. Introduced _PyFrameEvalFunction: the interpreter loop can be swapped for your own. This is the hook debuggers and JITs attach to — the loop is not the one immovable thing.https://peps.python.org/pep-0523/
  4. PEP 667 — Consistent views of namespacesPEP. Mark Shannon, Tian Gao; Final, Python 3.13. The reason f_locals for a function stopped being a snapshot dict and became a write-through proxy (FrameLocalsProxy): a write reaches the frame itself. Verified by running it — see bench/interpreter-loop/frames.py.https://peps.python.org/pep-0667/
  5. PEP 669 — Low Impact Monitoring for CPythonPEP. Mark Shannon; Final, Python 3.12. sys.monitoring: instrumentation reuses the same instruction-swap machinery (quickening) as specialization — hence monitoring costs nothing until it is turned on.https://peps.python.org/pep-0669/
  6. PEP 744 — JIT CompilationPEP. Brandt Bucher, Savannah Ostrowski; status **Draft** (not Final — must be stated). Describes the copy-and-patch JIT, built optionally and enabled only for hot regions. Hence the article's caveat: the JIT ships, but experimental.https://peps.python.org/pep-0744/
  7. What's New in Python 3.14 — the experimental tail-call interpreterOfficial documentation. The only primary source on the third dispatcher variant: “a new type of interpreter … uses tail calls between small C functions”. It also gives the only number to quote — “a geometric mean of 3-5% faster on the standard pyperformance benchmark suite”, against a baseline of “Python 3.14 built with Clang 19, without this new interpreter”. Not reproducible by hand without a rebuild.https://docs.python.org/3.14/whatsnew/3.14.html
  8. dis and opcode — pseudo-instructions, HAVE_ARGUMENT, specialized opcodesOfficial documentation. The source of the opcode-set figures: HAVE_ARGUMENT — the boundary below which opcodes take no argument (90 on 3.12, 44 on 3.13, 43 on 3.14), and the count of named opcodes grows (140 → 150 → 238) through specialized forms. Verified by running it.https://docs.python.org/3.14/library/dis.html