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
- Finished bytecode needs someone to run it. That someone is one C loop — it takes opcode after opcode and does what each says. One loop for all calls, not one loop per function.
- A call frame is not a separate heap object. Locals and the value stack live as a compact chunk on the thread's stack: on a call the pointer moves forward, on return it moves back. No memory allocation per call.
- So the recursion limit (1000 by default) is a separate counter of Python frames, not the system-stack limit.
- Details are version-bound: in 3.14 loading a local got cheaper (
LOAD_FAST_BORROW), and since 3.13 a function'sf_localsis no longer a snapshot but "live" — a write to it reaches the frame.
Who runs the bytecode
In the previous lesson source turned into bytecode. But bytecode does not run itself — a single C loop inside the interpreter spins it: take an opcode, act on it, move to the next. The same loop serves all calls: when one function calls another, no second loop starts — the same loop begins spinning a new frame.
Where a frame lives
It is easy to think each call creates a "frame object" in the heap. It does not. A call's local variables, value stack, and bookkeeping live as a compact chunk on the thread's stack. Call a function — the pointer moves forward; return — it moves back. No allocation and no garbage collection per call.
The "frame object" you see in an error traceback is overlaid on top lazily — only if someone actually needs it.
That is why the recursion limit is a separate counter (1000 by default), not the system-stack limit: it counts Python frames.
What changes between versions
The instruction set and frame behaviour move — you cannot hard-code specific opcode numbers. Two notable changes:
Loading a local got cheaper (3.14). The same line return a + 1 used to load
a via LOAD_FAST; in 3.14 it is LOAD_FAST_BORROW: less wasted work on the
most frequent operation.
f_locals became "live" (3.13). A function's f_locals used to hand back a
snapshot — a copy of the locals, writes to which never reached the variables
themselves. Since 3.13 it is a "live" object: write to it and the real variable
changes. This is what debuggers that change locals on the fly need.
Takeaways
- Bytecode is run by one C loop, shared across all calls.
- A frame is a chunk on the thread's stack, not a heap object; so a call is cheap, and the recursion limit is a separate counter.
- Details (opcode numbers,
f_localsbehaviour) depend on the version — do not rely on them as fixed.
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_FAST → LOAD_FAST_BORROW in 3.14. A function's
f_locals: snapshot dict → write-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:
def g():
x = 1
loc = sys._getframe().f_locals
loc["x"] = 99 # write into f_locals
return x- Before 3.13:
g()returns1.f_localswas a snapshotdict; a write to it did not affect the real local. - Since 3.13:
g()returns99.f_localsis now aFrameLocalsProxy— write-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_FAST → LOAD_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.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- The bytecode from the previous lesson needs someone to run it. That is one C loop —
_PyEval_EvalFrameDefaultinPython/ceval.c: a giantswitchover 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
_PyInterpreterFramestruct laid as a chunk onto the thread's data stack, notmalloc'd per call. The frame object atracebacksees is built lazily on top of it. - The dispatcher comes in three kinds. A plain
switch, acomputed 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_FAST→LOAD_FAST_BORROWin 3.14. A function'sf_locals: snapshotdict→ write-throughFrameLocalsProxysince 3.13 (PEP 667).
In fact
- A call's data lives in the
_PyInterpreterFramestruct 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. - 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. - 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. - Before 3.13 it was a snapshot: a write did not reach the frame (
xstayed the same). Since 3.13 (PEP 667) it is aFrameLocalsProxy— write-through: a write changes the real local. Verified by running it:loc["x"]=99gives1before 3.13 and99since 3.13. - 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 theopcodemodule rather than hard-coding it.
What is covered
- One loop for all calls
- A frame does not live in the heap
- How the next instruction is chosen
- The opcode set moves
- `f_locals` is no longer a snapshot
- Where the laziness ends
- Instrumentation and the JIT sit on the same machinery
- What to take away
Common misconceptions
“Every function call creates a frame object in the heap.”
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.
“The recursion limit is the C system-stack limit.”
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.
“The interpreter loop is an immovable CPython constant.”
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.
“f_locals is the locals dict, and you can write to it.”
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.
“Opcode numbers are stable, you can hard-code them.”
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
A Python function calls another Python function. What happens to the interpreter's C stack?
Sources & further reading
8 SOURCES
- 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
- 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
- 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/
- 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/ - 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/
- 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/
- 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
- 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