skylib index
Reference · CPython 3.14.4 · x86-64 · Debian/Linux

Descent & Return

Five lines of Python, followed all the way down to the silicon and back out to the photons hitting your eye. Every listing on this page was produced by actually running it — nothing here is recalled from memory.

hello.py
def add(a, b):
    return a + b

total = add(2, 3)
print(f"total = {total}")
Your codeThe program you wrote and the data it makes. Follow the chartreuse and you are following yourself.
CPythonA C program someone else compiled. It is the thing that runs; your code is what it reads.
KernelRing 0. Owns the hardware. You reach it only by asking, and asking is expensive.
SiliconRegisters, caches, execution ports. Knows nothing about Python whatsoever.
TerminalA separate process drawing glyphs. It never sees your program — only bytes.

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 nine stations · descent is gradual, exit is abrupt
HUMAN-READABLE MACHINE SILICON 01fork · exec 02boot 03compile 04bytecode 05eval loop 06objects 07the CPU 08syscall 09pixels one write() syscall — and you are out six layers of software to get down… …one instruction to leave.
Read the colours, not just the numbers. The sequence goes kernel → CPython → your data → CPython → silicon → kernel → terminal. Your own code owns exactly one station on this map. Everything else is other people's programs, doing things on your behalf.
01

The shell makes a process

Altitude · userspace, ground level · nothing Python-specific has happened yet

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.

what the binary actually is · distro-packaged python3
$ 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 page
  libm.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
Note what is missing. There is no 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.

execve: same process, new program
bashpid 4021blocked in wait() fork() bash (copy)pid 4188identical twin execve() python3 pid 4188 — the same pid fresh address space fds 0, 1, 2 inherited ld-linux.so maps libc, resolvessymbols, jumps to entry The file descriptors survive execve — that inheritance is the only reason your program's fd 1 is already wired to the terminal by the time main() starts.

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.

02

CPython boots itself

Altitude · a C program starts running · still no Python executed

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.

proof that a universe exists before you do
$ 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 -8
  self [µs] | cumulative | imported package
import 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
Thirty-three modules, and you haven't written a line yet. The indentation is an import tree: 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.)
03

Source becomes bytecode

Altitude · text → structure → instructions · a real compiler, running right now

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.

python3 -c "import tokenize,io; …"
  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'
Flat character soup becomes a flat list of typed lumps. Indentation — invisible in most languages — is promoted here into explicit INDENT/DEDENT tokens.
ast.dump(ast.parse(src), indent=2)
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 PEG parser turns the token list into a tree. 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.
the invisible pass
# 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
Before emitting anything, the compiler walks the tree deciding the scope of every name. This is why 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.
python3 -m dis hello.py
  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_VALUE
The output of the whole pipeline: a flat instruction sequence for an abstract stack machine that does not exist in hardware. Note LOAD_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.

04

Bytecode is data, not instructions

Altitude · the pivot · the single most clarifying idea on this page

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.

the anatomy of add()
>>> 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]
Eighteen bytes for a two-line function — and ten of them are zero. Those zeros are not padding. They are inline cache slots that the interpreter will write into as it learns what types your function actually receives. We come back to them in station 5.
Decoding the eighteen bytes by hand
80 00 57 01 2c 00 00 00 00 00 00 00 00 00 00 00 23 00 0123456 – 151617 RESUME 0interpreter entry · signal and trace check LOAD_FAST_BORROW_LOAD_FAST_BORROW 1one opcode, two loads — a 3.14 superinstruction BINARY_OP 0 (+)fully generic addition — for now 5 inline cache entries · 10 bytesscratch space the interpreter writes into at runtime RETURN_VALUE

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.

05

The eval loop

Altitude · a 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.

miniterp.py — a working Python VM, in Python
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)))
Output: 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.

Bytecode stepper · hello.py 0 / 0
Instructions
Frame stack
Value stack · top at the top
Two stacks, doing different jobs. The value stack holds operands for the instruction currently executing. The frame stack holds one frame per active function call, each with its own value stack and its own locals array. 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.

the same function, warmed up two different ways
>>> def add(a, b): return a + b

>>> dis.dis(add, adaptive=True)          # cold
      RESUME                   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 calls
      RESUME_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 (+)
Identical source, divergent bytecode. 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:

def add(a, b): return a + b
─── Python 3.10 ────────────────────────────────
  2   0 LOAD_FAST                0 (a)
      2 LOAD_FAST                1 (b)
      4 BINARY_ADD
      6 RETURN_VALUE
      8 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_VALUE
      18 bytes · 4 instructions + 10 bytes of cache
VersionWhat arrivedWhy it shows up above
3.11Specialising adaptive interpreter (PEP 659); zero-cost exceptions; inlined Python-to-Python callsInline caches appear; BINARY_ADD becomes the specialisable BINARY_OP
3.12Immortal objects (PEP 683); comprehension inlining; per-interpreter GILsys.getrefcount(2) starts returning a sentinel instead of a count
3.13Experimental copy-and-patch JIT; free-threaded build (PEP 703); new REPLThe "bytecode is never machine code" rule gains an asterisk
3.14Superinstructions like LOAD_FAST_BORROW_LOAD_FAST_BORROW; LOAD_SMALL_INT; tail-call interpreter; free-threading officially supportedTwo 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.

06

Objects in RAM

Altitude · addresses and bytes · id() is telling you the truth

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.

The 28 bytes of the integer 2
ob_refcnt8 bytes · Py_ssize_t ob_type8 bytes · ptr to PyLong_Type lv_tag8 bytes · digits, sign, flags ob_digit4 bytes ← the actual 2 offset 0offset 8offset 16offset 24 28 bytes total — sys.getsizeof(2) == 28 The first 16 bytes are the PyObject header, identical for every object in the language: a list, a dict, a function, your module — all of them start with a refcount and a type pointer.
That third field used to be 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.
reading an object's refcount straight out of process memory
>>> 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.
No debugger, no C extension. You just read your own interpreter's heap using nothing but ctypes, and confirmed the object header is laid out exactly as described.

Small integers live somewhere strange

two addresses, two different worlds
>>> 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
Two discoveries in one listing. First: integers from −5 to 256 are created once at startup and live in the binary's static data segment, not the heap — which is why 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.

ObjectgetsizeofNotes
int 028Header dominates; the payload is 4 bytes
int 2**6436Arbitrary precision — grows in 4-byte digits
'' (empty str)41Header plus hash, length, kind and flags
'total = 5'5041 + 9 chars, one byte each (compact ASCII)
[] (empty list)56Excludes the elements, which are separate objects
{} (empty dict)64The most optimised structure in the language
string interning — compile time vs runtime
>>> 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
This is the mechanism behind fast attribute access: every name in co_names is interned, so obj.method can compare dictionary keys by pointer before falling back to comparing characters.
07

The processor

Altitude · the nadir · nothing here has ever heard of Python

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:

Fetch · decode · execute · write back
FETCH read instructionat address in RIP DECODE what operation,which registers EXECUTE the ALU doesthe arithmetic MEMORY load or store,if needed WRITE BACK result intoa register repeat, forever, billions of times a second

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 rax can 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.

the caches on the machine this page was built on
$ lscpu | grep -i cache
  L1d cache:    32 KiB      ← ~4 cycles
  L1i cache:    32 KiB
  L2 cache:     1 MiB       ← ~14 cycles
  L3 cache:     33 MiB      ← ~50 cycles
Your bytecode is 18 bytes. Your eval loop is a few tens of kilobytes. Both fit comfortably in L1 and stay there. The objects they point at do not — chasing a pointer to an 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.

08

Crossing into the kernel

Altitude · climbing out · the one moment you leave your own process

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.

sys.stdout is three objects in a trench coat
# 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
Python inspects the destination and changes its behaviour. Attached to a terminal it flushes on every newline so you see output as it happens. Redirected to a file or pipe it batches into 8 KB chunks, because syscalls are expensive and nobody is watching. This is the entire explanation for output appearing "out of order" when you pipe a program somewhere.
buffering, demonstrated rather than asserted
# buf.py
import sys, os
sys.stdout.write("A")              # goes into Python's buffer
os.write(2, b"[stderr marker]")   # raw syscall, no buffer
sys.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 write path · one syscall, five translations
USERSPACE — YOUR PROCESS 'total = 5\n'a str object — 10 Unicode code points TextIOWrapper.write() 74 6f 74 61 6c 20 3d 20 35 0aUTF-8 encoded — 10 bytes BufferedWriter — held until \n (tty) or 8 KB (pipe) FileIO.write(fd=1, buf, 10)a C call — still inside your address space syscall instruction — ring 3 crosses to ring 0 KERNEL — RING 0 sys_writefd 1 → struct file → VFS tty line disciplineONLCR: your \n becomes \r\n pty master buffera different process is polling this The kernel does not draw anything. It copies your ten bytes into a buffer, applies a couple of character translations, marks the pseudo-terminal readable, and returns. Your process resumes with no idea whether anything was displayed — and no way to find out.

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.

09

Bytes become photons

Altitude · surface · a different process, a different program, a GPU

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.

From pty buffer to lit pixels
read()wakes from poll(),pulls 11 bytes VT parserstate machine huntingfor escape sequences grid modelcells[row][col] = charplus fg, bg, attributes shapingHarfBuzz: code points→ glyph ids, fallbacks rasteriseFreeType: outlines →coverage bitmaps, cached glyph atlas → GPUone texture, one quadper visible cell compositorWayland or X11 blendsevery window into one framebuffera large array of RGBvalues sitting in VRAM scanoutdisplay controller reads it 60–144×/sec Between the syscall returning and the glyph being visible: one or two frames. Perhaps 8 to 30 milliseconds. In that window your process executed millions more instructions, or finished and exited entirely.

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

Debian's opinions · and the asterisk on station 4

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 opinionConsequence
python does not existOnly 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-packagesA 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 outpython3-venv and python3-dev are separate packages. A stock Debian cannot create a virtualenv until you install one.
Conservative versionBookworm 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 offDebian 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.