The route is not a loop. Output does not retrace the path input took. You descend through six layers of software to reach the processor, and then leave by an entirely different door — a syscall into the kernel, which hands your bytes to a different process that happens to be drawing letters. Nothing "returns" in the call-stack sense. Keeping that asymmetry in view is most of the battle.
The shell makes a process
You type python3 hello.py and press Return. Before a single byte of your file is read, the shell has to conjure a new process and pour a different program into it.
Bash searches $PATH directory by directory for an executable called python3, then performs the oldest trick in Unix: it calls fork() to clone itself, and the clone immediately calls execve() to replace its own program image with Python's. Same process ID, entirely new contents. The shell then blocks in wait() until the child dies — which is why your prompt doesn't come back until the program finishes.
$file $(which python3) python3.12: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bd4fbd1c…, for GNU/Linux 3.2.0, stripped$readelf -h $(which python3) Class: ELF64 Data: 2's complement, little endian Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x6576a0$ldd $(which python3) linux-vdso.so.1← kernel-injected fast-syscall pagelibm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
libpython in that list — the interpreter is linked directly into the binary. The separate libpython3.x.so that Debian also ships exists for programs that embed Python, not for the command you just ran. What is there is distro policy made visible: libz and libexpat are dynamically linked because Debian refuses to vendor copies of libraries it already packages.Provenance, since this page keeps promising it. The listing above is a distro-packaged python3.12 (Ubuntu 24.04, glibc 2.39 — same packaging lineage as Debian trixie). Every other listing on this page comes from a uv-installed CPython 3.14.4, and that binary differs here: it reports for GNU/Linux 2.6.32, is not stripped, and still lists libpthread.so.0 and libdl.so.2 separately — glibc 2.34 folded both into libc, so their absence above dates the toolchain rather than the interpreter. Debian proper may also build PIE (Type: DYN) depending on release; this Ubuntu build does not, so treat that row as distro-dependent rather than settled. None of it disturbs the point: no libpython in either list.
The shell never opened your terminal for you. File descriptors 0, 1 and 2 were opened by the terminal emulator long before bash existed, and they have been passed down by inheritance through every fork since. Your program will write to fd 1 in station 8 without ever having opened anything.
CPython boots itself
The thing about to "run" your program is itself a large, already-compiled C program that must set up an entire object universe before it can evaluate a single line.
Between main() and your first statement, CPython constructs a runtime: it allocates the interpreter and thread states, builds the builtins module so that names like print exist at all, sets up sys.modules, configures the import machinery, reads environment variables like PYTHONPATH, initialises the memory allocator's arenas, and takes the Global Interpreter Lock. Only then does it open hello.py.
$python3 -c "import sys; print(len(sys.modules))" 33← modules already loaded before your first line$python3 -c "import sys; print(', '.join(sorted(sys.modules)[:14]))" __main__, _abc, _codecs, _collections_abc, _frozen_importlib, _frozen_importlib_external, _imp, _io, _signal, _sitebuiltins, _stat, _thread, _warnings, _weakref$python3 -X importtime hello.py 2>&1 | tail -8self [µs] | cumulative | imported packageimport time: 97 | 97 | genericpath import time: 164 | 363 | posixpath import time: 519 | 1958 | os import time: 107 | 107 | _sitebuiltins import time: 86 | 86 | sitecustomize import time: 31 | 31 | usercustomize import time: 371 | 2551 | site total = 5
site pulled in os, which pulled in posixpath, which pulled in genericpath — 2.5 milliseconds of cascade before your file is even opened. This is why Python's startup is measured in tens of milliseconds while a C binary's is measured in microseconds. (The exact count varies by version, build and environment — a Debian-packaged 3.12 will differ. The order of magnitude is the point.)Source becomes bytecode
Python is compiled. It is compiled every time you run it, in milliseconds, to a target that is not your CPU.
Your file is read as text and pushed through four transformations. Each one is available to you directly from the standard library, which means you can watch the compiler work.
NAME 'def'
NAME 'add'
OP '('
NAME 'a'
OP ','
NAME 'b'
OP ')'
OP ':'
NEWLINE
INDENT ← whitespace becomes a real token
NAME 'return'
NAME 'a'
OP '+'
NAME 'b'
NEWLINE
DEDENT
NAME 'total'
OP '='
NUMBER '2'INDENT/DEDENT tokens.Module(
body=[
FunctionDef(
name='add',
args=arguments(
args=[
arg(arg='a'),
arg(arg='b')]),
body=[
Return(
value=BinOp(
left=Name(id='a', ctx=Load()),
op=Add(),
right=Name(id='b', ctx=Load())))])])a + b is now a BinOp with a left, an op, and a right — structure, not sequence. Every linter, formatter and type checker you use operates on exactly this.# Not directly exposed, but you can see its verdict:>>> add.__code__.co_varnames ('a', 'b')← decided to be LOCAL>>> add.__code__.co_names ()← nothing global referenced>>> compile(src,'x','exec').co_names ('add', 'total', 'print')← module scope: all global
a becomes a fast array slot inside add, while print stays a dictionary lookup at module level — and why UnboundLocalError is a thing that can exist. 1 RESUME 0
1 LOAD_CONST 0 (<code object add>)
MAKE_FUNCTION
STORE_NAME 0 (add)
4 LOAD_NAME 0 (add)
PUSH_NULL
LOAD_SMALL_INT 2
LOAD_SMALL_INT 3
CALL 2
STORE_NAME 1 (total)
5 LOAD_NAME 2 (print)
PUSH_NULL
LOAD_CONST 1 ('total = ')
LOAD_NAME 1 (total)
FORMAT_SIMPLE
BUILD_STRING 2
CALL 1
POP_TOP
LOAD_CONST 2 (None)
RETURN_VALUELOAD_SMALL_INT — a 3.14 opcode that encodes the value directly in the instruction, so tiny integers skip the constants table entirely.Where the .pyc comes in. For an imported module, CPython caches the compiled result in __pycache__/name.cpython-314.pyc and skips stations 3 on subsequent runs — that file is a header plus the marshalled code object. The script you invoke directly is never cached, which is why hello.py is recompiled on every run.
Bytecode is data, not instructions
Your bytecode is never executed by the CPU. It is read by a C program the way a CSV is read by a parser. This is the distinction everything else depends on.
The compiler's output is a code object — an ordinary Python object you can hold, inspect and pick apart. Its co_code field is a bytes object. Bytes. Data. Passive.
>>> c = add.__code__ >>> c.co_argcount 2 >>> c.co_nlocals 2 >>> c.co_stacksize 2← max value-stack depth, computed at compile time>>> c.co_varnames ('a', 'b') >>> c.co_consts (None,) >>> c.co_names () >>> c.co_code.hex(' ') 80 00 57 01 2c 00 00 00 00 00 00 00 00 00 00 00 23 00 >>> list(c.co_code) [128, 0, 87, 1, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 35, 0]
Every instruction is exactly two bytes
One byte of opcode, one byte of argument. Arguments larger than 255 are built up with EXTENDED_ARG prefixes. That regularity is what makes the interpreter loop fast — and what makes it possible to write your own in fifty lines, which we do next.
The eval loop
while loop with a very large switch · the beating heart"The Python interpreter" is a single C function called _PyEval_EvalFrameDefault. It fetches a byte, jumps to the handler for that byte, mutates a stack, and repeats. That is the entire mystery.
Because your bytecode is just data, you can write your own interpreter for it in Python. This one is real: it executes the actual code object produced by the actual compiler, and gets the right answer.
import dis
def run(code, args=()):
stack = [] # the value stack
local = list(args) + [None] * (code.co_nlocals - len(args))
instrs = list(dis.get_instructions(code, adaptive=False))
ip = 0
while ip < len(instrs):
ins = instrs[ip]; ip += 1
op, arg = ins.opname, ins.arg
if op in ("RESUME", "NOP", "CACHE"):
pass
elif op == "LOAD_FAST":
stack.append(local[arg])
elif op == "LOAD_FAST_BORROW_LOAD_FAST_BORROW":
stack.append(local[arg >> 4]); stack.append(local[arg & 15])
elif op == "LOAD_SMALL_INT":
stack.append(arg)
elif op == "LOAD_CONST":
stack.append(code.co_consts[arg])
elif op == "STORE_FAST":
local[arg] = stack.pop()
elif op == "BINARY_OP":
r = stack.pop(); l = stack.pop()
stack.append({0: lambda a,b: a+b, 10: lambda a,b: a-b,
5: lambda a,b: a*b}[arg](l, r))
elif op == "RETURN_VALUE":
return stack.pop()
else:
raise NotImplementedError(op)
def add(a, b):
return a + b
print(run(add.__code__, (2, 3)))5. Roughly forty lines of dispatch, and you have replaced CPython's evaluator for this function. The real one is the same shape, written in C, with about two hundred more cases and a great deal of care about reference counts.Step through the real thing
Below is the actual bytecode for hello.py, executed one instruction at a time. Watch the value stack, and watch the frame stack grow when add(2, 3) is called.
Instructions
Frame stack
Value stack · top at the top
CALL pushes a frame; RETURN_VALUE pops one and leaves the result on the caller's value stack.Bytecode that rewrites itself
Here is where the modern interpreter stops matching the textbook. Since 3.11, CPython ships a specialising adaptive interpreter: instructions watch the types flowing through them and, once confident, overwrite themselves in memory with a narrower, faster variant. Those ten zero bytes from station 4 are where the evidence is stored.
>>> def add(a, b): return a + b >>> dis.dis(add, adaptive=True)# coldRESUME 0 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b) BINARY_OP 0 (+) RETURN_VALUE >>> for _ in range(500): add(2, 3) >>> dis.dis(add, adaptive=True)# after 500 int callsRESUME_CHECK 0 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b) BINARY_OP_ADD_INT 0 (+) RETURN_VALUE# and a twin fed strings instead:>>> for _ in range(500): add2("x", "y") BINARY_OP_ADD_UNICODE 0 (+)
BINARY_OP is a fully general dispatch through __add__, __radd__, type coercion and overflow checks. BINARY_OP_ADD_INT is a guard plus a native add. The interpreter observed reality and specialised. If a float shows up later, the guard fails and the instruction quietly de-optimises back to generic.What changed, and when
Comparing the same function across versions is the quickest way to see the last decade of interpreter work:
─── Python 3.10 ────────────────────────────────2 0 LOAD_FAST 0 (a) 2 LOAD_FAST 1 (b) 4 BINARY_ADD 6 RETURN_VALUE8 bytes · 4 instructions · one op per concept─── Python 3.14 ────────────────────────────────2 RESUME 0 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b) BINARY_OP 0 (+) RETURN_VALUE18 bytes · 4 instructions + 10 bytes of cache
| Version | What arrived | Why it shows up above |
|---|---|---|
| 3.11 | Specialising adaptive interpreter (PEP 659); zero-cost exceptions; inlined Python-to-Python calls | Inline caches appear; BINARY_ADD becomes the specialisable BINARY_OP |
| 3.12 | Immortal objects (PEP 683); comprehension inlining; per-interpreter GIL | sys.getrefcount(2) starts returning a sentinel instead of a count |
| 3.13 | Experimental copy-and-patch JIT; free-threaded build (PEP 703); new REPL | The "bytecode is never machine code" rule gains an asterisk |
| 3.14 | Superinstructions like LOAD_FAST_BORROW_LOAD_FAST_BORROW; LOAD_SMALL_INT; tail-call interpreter; free-threading officially supported | Two loads fuse into one opcode; small integers skip co_consts |
"Borrow" is a refcount word. LOAD_FAST_BORROW pushes a pointer onto the stack without incrementing the object's reference count, because the interpreter can prove the local variable keeps it alive for the duration. Every avoided increment is an avoided write to shared memory — which matters enormously once the GIL is gone.
Objects in RAM
Everything on that value stack was a pointer. In CPython, id(x) returns the actual memory address of the object — which means you can reach into the process's own memory and read an object's internals with nothing but the standard library.
Every Python object begins with the same header: a reference count and a pointer to its type. A small integer adds a size field and the digits themselves. That is why the number 2 costs twenty-eight bytes.
ob_size. Since 3.12 it is lv_tag, which packs the digit count, the sign and some flags into a single word — offset and width are unchanged, so the byte arithmetic above still holds. You can read the packing yourself: ctypes.c_size_t.from_address(id(2)+16) returns 12, which is 1100 in binary — one digit (12 >> 3), positive, plus a flag bit that is set on the cached small integers and clear on ordinary ones. Try -7 and you get 10: one digit, negative. Try 2**40 and you get 16: two digits, positive.>>> import sys, ctypes >>> x = 12345678 >>> addr = id(x) >>> addr 139780828826256# == 0x7f2142a60a90 — a real heap address# first 8 bytes at that address ARE the refcount:>>> ctypes.c_ssize_t.from_address(addr).value 3 >>> sys.getrefcount(x) 4# 4, because getrefcount's own argument counts# next 8 bytes are the type pointer:>>> hex(ctypes.c_void_p.from_address(addr + 8).value) '0x15a46d0' >>> hex(id(int)) '0x15a46d0'# ← same. ob_type points at the int type object.
ctypes, and confirmed the object header is laid out exactly as described.Small integers live somewhere strange
>>> id(2) 23047864# 0x15faeb8 — low. static data in the binary.>>> id(12345678) 139780828826256# 0x7f21… — high. the mmap'd heap.>>> a = 2; b = 2; a is b True# same object — preallocated at startup>>> 5 is (2 + 3) True# your addition returned a pointer to a singleton>>> sys.getrefcount(2) 3221225472# 0xC0000000 — not a count. an IMMORTAL marker.>>> sys.getrefcount(257) 7# an ordinary, mortal object
id(2) is a seven-digit number while a real object's is fifteen. Second (new in 3.12, PEP 683): those objects are immortal. Their refcount field holds a sentinel that the interpreter never modifies, so millions of threads can share them without a single atomic write. (The 7 for 257 will not reproduce exactly — it counts whatever else in your session happens to hold a reference. Mortal is the point, not the number.)Your program touched this. add(2, 3) returned 5 — and that 5 was not constructed. BINARY_OP_ADD_INT computed the value, found it in the small-integer cache, and returned a pointer to an object that has existed since the interpreter started. No allocation happened at all.
Where everything else comes from
For objects too big or too transient for that treatment, CPython does not call malloc per object. It runs its own allocator, pymalloc, three levels deep: it requests 1 MB arenas from the OS, carves them into 16 KB pools each dedicated to one size class, and hands out blocks in 8-byte increments. Allocating a small object is usually just popping a pointer off a free list.
| Object | getsizeof | Notes |
|---|---|---|
| int 0 | 28 | Header dominates; the payload is 4 bytes |
| int 2**64 | 36 | Arbitrary precision — grows in 4-byte digits |
| '' (empty str) | 41 | Header plus hash, length, kind and flags |
| 'total = 5' | 50 | 41 + 9 chars, one byte each (compact ASCII) |
| [] (empty list) | 56 | Excludes the elements, which are separate objects |
| {} (empty dict) | 64 | The most optimised structure in the language |
>>> s1 = "total"; s2 = "total" >>> s1 is s2 True# identifier-like literals are interned by the compiler>>> s3 = "".join(["to", "tal"]) >>> s3 == s1, s3 is s1 (True, False)# equal value, different object — built at runtime>>> sys.intern(s3) is s1 True# forced into the intern table
co_names is interned, so obj.method can compare dictionary keys by pointer before falling back to comparing characters.The processor
We have reached the bottom. The CPU is executing _PyEval_EvalFrameDefault — compiled x86-64 machine code, indistinguishable to the hardware from a video codec or a kernel driver. Your bytecode is somewhere in its data cache, being read like a file.
The clean model
Every introduction starts here, and it is worth having, because it is the mental model the rest of the machine is pretending to honour:
What actually happens
The clean model has been a polite fiction since roughly 1995. A modern x86-64 core does all of the following at once, and the only contract it honours is that the observable results come out in program order:
- It decodes into micro-ops. Your x86 instruction is not the unit of execution. It is cracked into internal RISC-like μops, and frequently-executed loops are served from a μop cache that skips decoding entirely.
- It is superscalar. Four to eight μops are issued per cycle, to a set of specialised execution ports — several integer ALUs, a couple of load units, a store unit, vector units.
- It executes out of order. A reorder buffer holds hundreds of instructions in flight. If instruction 40 is waiting on memory and instruction 47 is ready, 47 runs first. Results are retired in order so you never notice.
- It renames registers. The sixteen architectural registers you can name are a fiction over a physical file of hundreds, which is how two independent uses of
raxcan run simultaneously. - It speculates. At a branch, it predicts the outcome and executes down the predicted path immediately. Correct: free. Wrong: fifteen to twenty cycles of work thrown away.
Why this bites interpreters specifically — with a caveat. The eval loop's central operation is an indirect jump through a table, to a target determined by a byte just loaded from your bytecode. On older cores that jump was close to unpredictable, and the folklore still says so. It is no longer true: ITTAGE-class indirect predictors, shipping since roughly Haswell, handle interpreter dispatch loops well — Rohou, Swamy and Seznec measured exactly this in "Branch prediction and the performance of interpreters — don't trust folklore" (CGO 2015). The dispatch is still not free: fetching and decoding the dispatch machinery itself costs, it serialises the front end, and it competes for instruction cache. That residual cost is what computed gotos, superinstructions, specialisation and 3.14's tail-call interpreter all attack — but the enemy is front-end pressure, not a mispredict on every instruction.
The memory hierarchy, honestly scaled
The processor is fast. Memory is not. Everything about performance follows from the size of that gap:
Bars are logarithmic — a linear scale would make everything but DRAM invisible. Figures are typical for a modern x86-64 server core.
$lscpu | grep -i cache L1d cache: 32 KiB← ~4 cyclesL1i cache: 32 KiB L2 cache: 1 MiB← ~14 cyclesL3 cache: 33 MiB← ~50 cycles
int on the heap is a potential cache miss, and this is the real reason a Python loop over a list is slower than a C loop over an array. It is not just the interpretation. It is the pointer chasing.The ratio that explains everything
One bytecode instruction — BINARY_OP in its generic form — costs somewhere between fifty and several hundred native instructions: decode, bounds-check, dispatch, unpack two PyObject*s, check types, look up __add__, perform the addition, allocate or fetch the result, adjust reference counts, push, advance the instruction pointer. The specialised BINARY_OP_ADD_INT collapses that to a guard and an add — perhaps a dozen. In C, a + b on two ints is one.
That spread, from one to several hundred, is the entire performance story of the language, and every optimisation in the 3.11–3.14 table above is an attempt to move the number leftward.
Crossing into the kernel
The turn. Everything so far happened inside one process's memory. To make anything appear anywhere, that process must stop, ask the kernel for a favour, and wait.
print() looks like one operation and is at least four. Your str passes down three stacked Python objects, becomes bytes, gets buffered, and eventually triggers a single write() syscall.
# run from a real terminal:sys.stdout -> _io.TextIOWrapper .encoding -> utf-8 .isatty() -> True .line_buffering -> True← because it IS a tty.buffer -> _io.BufferedWriter .buffer.raw -> _io.FileIO .fileno() -> 1# exact same program, output redirected to a pipe:.isatty() -> False .line_buffering -> False← now 8 KB block buffered
# buf.pyimport sys, os sys.stdout.write("A")# goes into Python's bufferos.write(2, b"[stderr marker]")# raw syscall, no buffersys.stdout.write("B\n")$python3 buf.py [stderr marker]AB# The marker appears FIRST even though "A" was written first.# "A" was still sitting in userspace memory when the raw# write() to fd 2 went straight through to the kernel.
The \r\n is real and you can see it. Run a program under a pty and capture the output: the newline you wrote comes back as carriage-return plus line-feed. You never asked for that. The kernel's terminal line discipline inserted it, because it is emulating a teletype from 1963 that needed a physical carriage return.
Bytes become photons
Your program is now finished — quite possibly exited — and the text still is not on screen. What happens next belongs entirely to someone else.
Your terminal emulator (GNOME Terminal, Alacritty, kitty, foot) has been sitting in poll() on the master end of the pseudo-terminal since before you typed the command. The kernel marks it readable; the emulator wakes.
The display controller does not know it is showing text. It reads a rectangular array of colour values out of VRAM, sixty or more times per second, and shifts them out over a cable. Somewhere in there, ten of those values are arranged in the shape of a 5.
The asymmetry, stated plainly. Input arrived as a filename and became structure, then instructions, then objects, then electrical state. Output left as bytes, and every layer that gave it meaning again — encoding, glyph, pixel — is a different program in a different process making its own decisions. Your program did not display anything. It asked the kernel to put ten bytes somewhere, and something else was watching.
Two footnotes that matter
Does Debian change any of this?
Stations 7 through 9 are pure kernel and hardware — the distribution is irrelevant there. Stations 1 through 6 are affected only in packaging, but the packaging has teeth:
| Debian's opinion | Consequence |
|---|---|
python does not exist | Only python3 is on $PATH. Install python-is-python3 if you want the bare name — station 1's PATH search fails otherwise. |
| PEP 668 marker (Debian 12+) | pip install outside a virtualenv is refused. Debian considers /usr/lib/python3/dist-packages to be apt's property. Use a venv or pipx; --break-system-packages means what it says. |
dist-packages, not site-packages | A Debian patch. Keeps apt-installed and pip-installed modules in separate directories, which changes what station 2 puts on sys.path. |
venv and pip are split out | python3-venv and python3-dev are separate packages. A stock Debian cannot create a virtualenv until you install one. |
| Conservative version | Bookworm ships 3.11, trixie ships 3.13. For 3.14 today you want uv python install 3.14, pyenv, or the deadsnakes PPA equivalent — Debian will not ship it mid-release. |
| The JIT is off | Debian builds without --enable-experimental-jit. Whatever the next section says, it is not running on your machine unless you built CPython yourself. |
The asterisk: does your bytecode ever become machine code?
Station 4 claimed your bytecode is never executed by the CPU — only read. That is true of every Python you are likely to be running, and it is the right model to hold. But it is worth knowing exactly where the edge is, because there are two very different things people mean by "JIT" and only one of them is genuinely new.
What you have been using since 3.11
The specialising adaptive interpreter from station 5. Instructions rewrite themselves into narrower variants. Nothing is compiled to machine code — BINARY_OP_ADD_INT is still a bytecode instruction, still dispatched by the same while loop, just cheaper. The model holds completely. This is on by default and it is why 3.11+ is meaningfully faster than 3.10.
What arrived in 3.13, experimentally
A copy-and-patch JIT. CPython identifies hot straight-line traces, and instead of dispatching through the loop, stitches together pre-compiled machine-code templates — one per micro-op — patching in the actual constants and pointers. For those traces, real x86-64 instructions derived from your Python are executed by the CPU, and station 4's claim gets its asterisk.
Three reasons not to reorganise your mental model around it: it must be enabled at build time and is off in every mainstream distribution including Debian; it covers a small fraction of executed code; and it changes performance, not semantics. The eval loop is still in charge. The JIT is an optimisation that some traces get to skip into, not a replacement for interpretation.
The one-line version. Your bytecode is data that a C program reads — always. Since 3.11 that data edits itself to make the reading faster. Since 3.13, on non-default builds, small hot stretches of it can additionally be turned into real machine code. Hold the first sentence; know the other two exist.