The first weird thing was the size.

On this Mac, the python3.13 executable is not a giant interpreter binary. It is 52,640 bytes.

$ stat -L -f '%z bytes %N' /opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13
52640 bytes /opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13

$ file /opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13
/opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13: Mach-O 64-bit executable arm64

Fifty-two kilobytes is not enough room for the whole Python runtime, the parser, the compiler, the object system, dictionaries, lists, integers of unbounded size, exceptions, imports, modules, Unicode, threads, the garbage collector, and whatever else is hiding behind print("hello").

So I asked the executable what it loads:

$ otool -L /opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13
/opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13:
	/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python (compatibility version 3.13.0, current version 3.13.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)

There it is. My python3.13 is a tiny Mach-O executable (Mach-O is macOS’s executable format, ELF’s counterpart on Linux) over a much larger CPython framework library:

$ stat -L -f '%z bytes %N' /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python
5080352 bytes /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python

$ file /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python
/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python: Mach-O 64-bit dynamically linked shared library arm64

This is a Homebrew framework build, so the library is called Python.framework/Versions/3.13/Python rather than libpython3.13.dylib. On Linux you might see a .so; on some builds you might get a less shared-looking executable. But the shape is the same enough for the point: the command you run is a door. Most of Python is on the other side.

And the other side is C.

$ nm -gU /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python | rg '_PyEval_EvalFrameDefault|PyEval_EvalCode|Py_BytesMain|Py_RunMain'
0000000000186fa0 T _PyEval_EvalCode
000000000019660c T _PyEval_EvalCodeEx
000000000021bc88 T _Py_BytesMain
000000000021b24c T _Py_RunMain
00000000001872e4 T __PyEval_EvalFrameDefault

nm -gU lists the library’s exported symbols, the names of functions and data a binary makes visible to other code; the T column marks a symbol defined in the text (code) section. The double underscore in __PyEval_EvalFrameDefault is Mach-O symbol spelling. The C function name is _PyEval_EvalFrameDefault.

_PyEval_EvalFrameDefault isn’t the only important function in CPython, and Python can’t be reduced to one C loop. But it’s where my code goes to actually happen, and I wanted to watch that trip. So I wrote the smallest program that has anything worth watching, a definition, a call, an addition, an output, and decided to follow it the whole way down and refuse to skip a step:

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

print(add(2, 3))
$ python3.13 add.py
5

Everything in this post is the biography of those five lines. Where the def goes. What the 2 physically is. Which C function performs the +. Who deletes the 5 afterward, and the answer to that one genuinely surprised me.

Two clarifications before the trip, because they cost me confusion early on. First: Python is a language; CPython is the C program that implements it, the one you almost certainly run as python3. PyPy, MicroPython, and friends are different programs with different answers to everything below. When someone says “Python is slow because it’s interpreted,” the first question is which Python. Second: the machine matters, because I’ll be pasting real output all the way through. Everything here is this build, on this Mac:

$ /opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13 -VV
Python 3.13.5 (main, Jun 11 2025, 15:36:57) [Clang 17.0.0 (clang-1700.0.13.3)]

You need beginner Python and enough C or C++ that pointers, structs, and function calls aren’t scary. Everything else gets explained when the five lines run into it.

The program that runs before mine

The symbol table from the framework library already sketched the startup sequence: _Py_BytesMain is where the tiny launcher enters the runtime with the raw command-line bytes, and _Py_RunMain is where CPython has enough configuration to decide what “running Python” means this time, a script file, -c, -m, an interactive prompt. Before add.py is even opened, CPython has to build the world that Python code assumes already exists: memory allocators, interpreter and thread state, sys, builtins, the import machinery, standard streams, signal handling, a __main__ module for my file to become.

That’s also why the 52 KB launcher stopped being surprising. The interesting machinery lives in the shared library; the executable just needs to be a process entry point that hands over control. (It’s why embedding Python inside a C++ application is a real, documented API, the normal python3.13 is merely the standard host program.) By the time my five lines get read, a lot of C has already run.

I wanted a number for “a lot,” so I asked the interpreter what already exists at the instant my file would begin, before a single line of add.py runs:

import sys, gc, builtins
print("modules:", len(sys.modules))
print("gc-tracked objects:", len(gc.get_objects()))
print("builtin names:", len(dir(builtins)))
modules: 55
gc-tracked objects: 7266
builtin names: 159

Fifty-five modules are already imported. Seven thousand-odd Python objects are already alive and already being watched by the cyclic garbage collector. A hundred and fifty-nine names, print and len and range and TypeError among them, are already sitting in the builtins namespace waiting for my code to look them up. None of that is my program. It is the world CPython builds so that my five lines have somewhere to happen.

That world costs real time. Timing twenty bare python3.13 -c "" starts, an interpreter that does nothing at all:

bare startup median 13.5 ms, min 13.2 ms

Thirteen milliseconds to open the door and stand in the empty room. -X importtime shows where a chunk of it goes, site pulling in re, which pulls in enum, functools, collections, a small avalanche of standard-library machinery imported before my script is even opened:

import time:       683 |       1826 |       enum
import time:       219 |       2590 |     re
import time:       173 |       4205 | site

Every number in those columns is microseconds; the right column is cumulative, including children. site alone, the module that sets up sys.path and finds site-packages, accounts for about 4.2 ms of startup importing things my add.py never mentions. This is the tax the 52 KB launcher hinted at, paid in full before _PyEval_EvalFrameDefault runs my first instruction. The door is small; the room behind it took thirteen milliseconds to furnish.

Feeding it five lines

Before CPython can execute anything, it has bytes, from a file, from -c, from the REPL (the interactive read-evaluate-print prompt). The bytes become decoded text, and the tokenizer, also called a lexer, cuts that text into tokens.

Here is Python’s own tokenize module chewing the top of add.py:

$ python3.13 -m tokenize add.py
1,0-1,3:            NAME           'def'
1,4-1,7:            NAME           'add'
1,7-1,8:            OP             '('
1,8-1,9:            NAME           'a'
1,9-1,10:           OP             ','
1,11-1,12:          NAME           'b'
1,12-1,13:          OP             ')'
1,13-1,14:          OP             ':'
1,14-1,15:          NEWLINE        '\n'
2,0-2,4:            INDENT         '    '
2,4-2,10:           NAME           'return'
2,11-2,12:          NAME           'a'
2,13-2,14:          OP             '+'
2,15-2,16:          NAME           'b'

(Trimmed, the full stream goes on through the print line and ends with DEDENT and ENDMARKER tokens.)

A token isn’t meaning yet, just a classified piece of text. def and add and a are all NAMEs; the tokenizer doesn’t yet know one is a keyword introducing a function, one is a function’s name, and one is a parameter. But look at line 2: INDENT is a token. The whitespace that makes Python Python is manufactured right here, by the lexer, as an explicit symbol in the stream. That’s the tokenizer’s whole personality: indentation, strings, comments, encodings, newlines. Structure is someone else’s job.

That someone is the parser. It takes the tokens and checks them against Python’s grammar, since 3.9, with a PEG parser, though you don’t need PEG theory for the runtime story. What matters is its output: a tree describing the structure of the program, the AST (abstract syntax tree). Python will show it to you:

import ast
print(ast.dump(ast.parse(open("add.py").read()), 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())))]),
    Expr(
      value=Call(
        func=Name(id='print', ctx=Load()),
        args=[
          Call(
            func=Name(id='add', ctx=Load()),
            args=[
              Constant(value=2),
              Constant(value=3)])]))])

Now the five lines have anatomy. A module whose body holds two things: a function definition (name add, two arguments, a body that returns a BinOp of two names) and an expression statement (a Call of print whose argument is a Call of add with the constants 2 and 3). The nesting of the two Call nodes is the evaluation order I intuitively knew, inner call first, written down as data.

That ctx=Load() on the names is a detail that looks fussy until you need it. The same name syntax means different things in different positions: a inside return a + b is being read (Load), while the add in def add is a name being bound. CPython marks the difference in the tree so it never has to rediscover it.

And still, nothing has run. No 2, no 3, no addition. CPython has converted text into a data structure.

The compiler cheats before the loop sees anything

The AST still isn’t what _PyEval_EvalFrameDefault executes. CPython compiles it into bytecode first.

Bytecode is a compact instruction stream for a virtual machine. It is not ARM64 machine code, your CPU can’t execute it directly. CPython’s C code reads the bytecode and performs the requested operations. dis, Python’s bytecode disassembler, shows what my five lines compile to:

$ python3.13 -m dis add.py
  0           RESUME                   0

  1           LOAD_CONST               0 (<code object add at 0x100d46330, file "add.py", line 1>)
              MAKE_FUNCTION
              STORE_NAME               0 (add)

  4           LOAD_NAME                1 (print)
              PUSH_NULL
              LOAD_NAME                0 (add)
              PUSH_NULL
              LOAD_CONST               1 (2)
              LOAD_CONST               2 (3)
              CALL                     2
              CALL                     1
              POP_TOP
              RETURN_CONST             3 (None)

Disassembly of <code object add at 0x100d46330, file "add.py", line 1>:
  1           RESUME                   0

  2           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

The first thing this dump corrected in my head: def is not a declaration. It’s an instruction. Line 1 of my file compiles to “load a code object constant, run MAKE_FUNCTION on it, store the result under the name add”, the function is built at runtime, when the module executes, exactly like any other statement. The body of add was compiled ahead of time into that code object; def just wraps and names it. And the call site is there too: load print, load add, load 2 and 3, CALL 2 for the inner call, CALL 1 for the outer one, throw away the result of print, return None from the module.

The body itself is three instructions. Two loads, one BINARY_OP, one return.

Then I tried a variant, because the AST section left a loose thread, the constants 2 and 3 sit in the call, not in the function. What if they were inside?

def folded():
    return 2 + 3
  3           RESUME                   0
              RETURN_CONST             1 (5)

I expected LOAD_CONST 2, LOAD_CONST 3, BINARY_OP. CPython did not bother. It computed 2 + 3 during compilation and emitted “return the constant 5”, no addition survives to runtime. This is the same move clang pulled in the toolchain post, where a page of template machinery collapsed to mov w0, #204: when the inputs are known at compile time, the work happens at compile time, and “interpreted language” turns out not to mean “reads your source line by line and does the obvious thing.” CPython tokenizes, parses, optimizes, and compiles before a single instruction runs. It’s an interpreter that is also a compiler to its own instruction set. (The compiler’s own insides, the symbol-table pass, the assembler, exception tables, deserve their own post.)

The bytecode lives inside a code object: an immutable object holding the instruction stream plus the metadata needed to run it. A function object points at a code object and adds the runtime parts, globals, defaults, closure cells (the little heap boxes that let an inner function keep using an outer function’s variables).

function object = callable runtime wrapper
code object     = compiled instructions and metadata
frame           = one active execution of a code object

A code object runs many times; every call gets a fresh frame.

You can inspect add’s code object from Python, which is both useful and slightly funny, Python peeking at the compiled object Python will use to run Python:

co = add.__code__
print("co_consts", co.co_consts)
print("co_varnames", co.co_varnames)
print("co_names", co.co_names)
print("co_argcount", co.co_argcount)
print("co_stacksize", co.co_stacksize)
co_consts (None,)
co_varnames ('a', 'b')
co_names ()
co_argcount 2
co_stacksize 2

co_consts holds only None, my 2 and 3 belong to the caller, and the implicit None return path is the function’s only literal. co_varnames is the local-variable slot table: a is slot 0, b is slot 1. co_stacksize 2 says the deepest the value stack ever gets is two entries, both operands on the stack, just before the add.

That slot table is what lets LOAD_FAST_LOAD_FAST 1 (a, b) be fast. It doesn’t look up the string "a" in a dictionary at runtime. The compiler already decided a and b are fast locals; the frame stores them in an array; the bytecode refers to them by index. Source code has names, the compiler turns as many as it can into integers.

Does CPython recompile add.py on every run? I checked, expecting a cache file, and learned a distinction instead: running a script leaves nothing behind, but importing it does.

$ rm -rf __pycache__ && python3.13 add.py
5
$ ls __pycache__
ls: __pycache__: No such file or directory
$ python3.13 -c "import add"
5
$ ls __pycache__
add.cpython-313.pyc

The .pyc holds the compiled bytecode plus metadata (the cpython-313 in the name is this build’s cache tag), not machine code. I opened it to see what “bytecode plus metadata” physically means. The first sixteen bytes are a header:

$ xxd __pycache__/add.cpython-313.pyc | head -1
00000000: f30d 0d0a 0000 0000 8fff 4c6a 3200 0000

The first four bytes, f3 0d 0d 0a, are the magic number that says “this is 3.13 bytecode.” Read the leading f3 0d as a little-endian 16-bit integer and it’s 3571; CPython bumps that number every time the bytecode format changes, so a .pyc from the wrong version is rejected instead of misinterpreted. The 0d 0a right after it is a literal \r\n, a deliberate booby trap: if some old text-mode file transfer mangles newlines, the magic breaks and the file reads as invalid rather than silently wrong. The next four bytes, 00 00 00 00, are a flags field. Zero means a timestamp-based cache, so the following eight bytes store the source file’s modification time and size, and import recompiles if either stops matching. The interpreter agrees with the file:

>>> import importlib.util
>>> importlib.util.MAGIC_NUMBER.hex()
'f30d0d0a'

Same four bytes. That equality is exactly how import decides whether the cache on disk is one this interpreter can trust. A valid cache skips tokenizing, parsing, and compiling on the next import; it never skips the eval loop. _PyEval_EvalFrameDefault doesn’t care whether a code object came from fresh compilation or a cache file. A frame is a frame. And notice import add printed 5, importing a module means executing it and caching the resulting module object in sys.modules, which is why ten files importing the same module run its top-level code once, not ten times. Import is runtime machinery, not textual include. (The import system’s full anatomy, finders, loaders, specs, path hooks, is somebody else’s post.)

The stack machine under the three instructions

Back to the body of add:

  2           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

The important word for reading bytecode is stack. CPython bytecode is stack-oriented: instructions mostly push values, pop values, or replace the top few with a result. For add(2, 3):

start:                    []
LOAD_FAST_LOAD_FAST a,b:  [2, 3]
BINARY_OP +:              [5]
RETURN_VALUE:             []

That looks like a toy VM from a compiler course because, in this slice, it is one. The difference is what the stack entries are. They aren’t raw integers. They’re pointers:

PyObject *stack_entry;

PyObject * is CPython’s “some Python object” pointer, and it’s the type that will keep appearing for the rest of this post. An integer is a PyObject *. A list is a PyObject *. A function, a module, an exception: all of them flow through the runtime as object pointers. So BINARY_OP + does not add two CPU integers sitting in registers. It receives two PyObject * values, asks the object system what addition means for their types, and produces another PyObject *.

At least at first. Once this site gets hot, runs enough times for CPython to notice a pattern, the bytecode itself changes, but that story needs the object model first.

A frame is the runtime copy of a call

When print(add(2, 3)) reaches its inner CALL 2, CPython doesn’t execute add’s code object naked; it wraps the call in an interpreter frame.

A frame is the runtime state for one active execution:

  • which code object is running
  • where the instruction pointer is
  • what the local variables are
  • what the value stack contains
  • what globals and builtins are visible
  • where to return or unwind on exceptions

If you know C, think of a frame as CPython’s own version of a stack frame, but for Python execution rather than for native machine execution. The CPU has real stack frames for C function calls. CPython also has interpreter frames describing Python calls.

When Python code calls Python code, CPython can often keep this in its own frame machinery rather than turning every Python call into a deep C recursion. Details change across versions, and 3.11+ did major frame/eval-loop surgery for performance, but the mental model remains: a Python call needs an object representing “we are currently running this code with these locals and this stack.”

That “rather than turning every Python call into a deep C recursion” is measurable, and the number the measurement produces surprised me. Python has a recursion limit, 1000 by default:

import sys
print(sys.getrecursionlimit())   # 1000

So I raised it absurdly high and recursed until something broke:

sys.setrecursionlimit(100000)
d = 0
def r():
    global d
    d += 1
    r()
try:
    r()
except RecursionError:
    print("stopped at depth", d)
stopped at depth 99999

It stopped at 99999 because Python’s own counter caught it, not because the C stack ran out and the process crashed. I half-expected a segfault. That it didn’t come is the frame restructuring paying off. In older CPython, every Python call sat on a real C stack frame inside _PyEval_EvalFrameDefault, and pushing the recursion limit too high really could kill the interpreter by exhausting the actual thread stack. Since 3.11, most Python-to-Python calls live in CPython’s own frame machinery on a separately managed stack, so the Python-level limit is now a policy number the runtime enforces on purpose, not a nervous guess at how much C stack is left before the OS pulls the trigger. The default 1000 is conservative because it can afford to be; you can raise it, within reason, without betting the process on it.

That frame is what _PyEval_EvalFrameDefault receives.

The code object is shared; the frame is per-call. This difference is easy to lose, so call the same function twice:

add(2, 3)
add("2", "3")

Both calls use the same code object, the same three instructions. But the first call’s frame has locals pointing at integer objects, and the second call’s frame has locals pointing at string objects, so the same BINARY_OP + produces 5 in one frame and "23" in the other. The bytecode is fixed; the objects in the frame decide what happens.

That is why CPython can’t compile add once into the single unconditional machine instruction a C compiler would emit:

add x0, x0, x1

The source-level + means “perform Python addition for whatever objects are currently in this frame,” not one CPU operation.

There’s another beginner trap here: local variables aren’t boxes containing values. They’re entries in the frame’s local storage, pointing at Python objects, and storing into a slot has to adjust reference counts so lifetimes stay correct, more on that machinery soon.

Look back at the module disassembly and you can see two different name worlds side by side. The module stored my function with STORE_NAME add and fetched the built-in with LOAD_NAME print, top-level names live in the module’s namespace, which is a dict, accessed by name. Inside add, the parameters were LOAD_FAST_LOAD_FAST, function locals are slots in an array, accessed by index. The opcode names in a disassembly (STORE_NAME, STORE_FAST, LOAD_GLOBAL, LOAD_FAST) tell you which kind of namespace the compiler proved it could use. Same language idea, different runtime path.

Global names are where the source hides the most runtime work. Neither print nor len is a local anywhere; CPython has to look them up. The lookup rule is familiar at Python level:

locals/globals first
builtins after that

At module top level, locals and globals are usually the same module dict. If the name isn’t in that dict, CPython looks in the builtins dict. So you can shadow builtins:

len = 99
print(len)

After that assignment, the global name len resolves to 99 in the module dict, not to the built-in function. Python didn’t reserve the word len; it was a normal name that happened to be found in builtins before you shadowed it.

It’s also the reason global lookup is slower than fast-local lookup in the general case. LOAD_FAST can index a frame slot. LOAD_GLOBAL has to consider dictionaries and versioning. Modern CPython specializes global loads too, using dictionary version tags (each dict carries a counter that bumps on every mutation) and inline caches (small scratch slots stored next to the bytecode) so repeated access to the same stable global or builtin can skip much of the generic lookup. The semantic rule says look in the right namespaces in the right order; the optimization caches the answer while the dictionaries haven’t changed; the guard stops trusting the cache the moment a dict version moves.

That guard is what keeps monkey-patching legal:

import math
math.sqrt = lambda x: "nope"

Python allows the world to change at runtime. CPython’s job is to go fast only while the world is still the one it cached.

The function signature in CPython 3.13 source is:

PyObject *
_PyEval_EvalFrameDefault(PyThreadState *tstate,
                         _PyInterpreterFrame *frame,
                         int throwflag)

The return value is a PyObject *: the Python value produced by the frame, or NULL if an exception is being propagated. The PyThreadState * is the current Python thread’s interpreter state. The _PyInterpreterFrame * is the active Python frame. throwflag is involved in resuming a frame by throwing an exception into it, generators can be resumed with gen.throw(exc), which raises the exception inside the paused frame.

That is the function named by the symbol table earlier.

The source file literally labels the area:

/* Interpreter main loop */

and then, a little below, defines _PyEval_EvalFrameDefault. It is a large C function. Large in the way runtime functions become large: fast paths, slow paths, exception paths, tracing hooks, recursion checks, generator handling, macro-generated opcode cases, instrumentation, specialization support, and all the “yes, but what if this object is weird?” that a dynamic language owes you.

The core idea is still simple enough to write as a cartoon:

for (;;) {
    opcode = next_instruction();

    switch (opcode) {
        case LOAD_FAST:
            push(local_variable);
            break;

        case BINARY_OP:
            right = pop();
            left = pop();
            result = PyNumber_Add(left, right);
            push(result);
            break;

        case RETURN_VALUE:
            return pop();
    }
}

That is not the real code. Modern CPython uses generated cases, inline caches, computed gotos when available (a compiler extension that jumps straight from one opcode’s handler to the next instead of bouncing through the top of a switch), fused opcodes, specialization, error handling, reference-count tricks, and a lot more. But the cartoon isn’t lying about the shape: a C loop reads bytecode instructions and runs C code for each instruction.

This is the main answer to “how can Python run if the CPU only runs machine code?”

The CPU is running the machine code produced from CPython’s C source. That C code is an interpreter for Python bytecode. Your Python source is not machine code; it becomes bytecode; CPython’s native code interprets that bytecode.

It helped me to run add(2, 3) on paper, as if I were the eval loop:

frame locals:
  slot 0: a -> PyLong(2)
  slot 1: b -> PyLong(3)

value stack:
  []

RESUME
  bookkeeping for entering/resuming the frame
  stack: []

LOAD_FAST_LOAD_FAST a,b
  push local slot 0
  push local slot 1
  stack: [PyLong(2), PyLong(3)]

BINARY_OP +
  pop right
  pop left
  perform Python addition
  push result
  stack: [PyLong(5)]

RETURN_VALUE
  pop return value
  leave frame
  return PyLong(5) to the caller's CALL 2,
  which leaves it on the module frame's stack for print

The real interpreter doesn’t print these steps, of course, and it has to maintain references correctly at every transition. If a store overwrites an old value in a slot, the old object may need a Py_DECREF. If a newly created result is stored, the frame now owns a reference. If an error happens halfway through BINARY_OP, the stack has to be unwound without leaking the operands.

This is where “a simple stack machine” stops being simple. The bytecode model is tidy. The implementation is full of lifetime edges.

The eval loop also has to periodically notice things that aren’t ordinary bytecode:

  • pending signals, like Ctrl-C
  • scheduled calls from other runtime machinery
  • tracing or profiling hooks
  • async exceptions
  • thread switching and GIL drop requests in the default build
  • recursion limits and stack checks

CPython has internal “breaker” style checks for this. You don’t need the exact field names to understand the point: the eval loop can’t run forever in a private tunnel. It must occasionally ask, “is there runtime work I need to handle before the next Python instruction?”

So a KeyboardInterrupt can appear while your Python loop is running. The CPU didn’t magically interrupt Python source. The OS delivered a signal, CPython recorded that something is pending, and the eval loop reached a point where it checked and turned that pending event into a Python exception.

What BINARY_OP + has to do

The beginner version of a + b is “add them.”

The CPython version starts with “what are they?”

1 + 2
"a" + "b"
[1] + [2]
10.5 + 2
some_object + other_object

Same syntax. Different behavior.

For exact integers, addition can eventually become integer arithmetic over CPython’s internal long representation. For strings, addition creates a new string. For lists, addition creates a new list containing elements from both operands. For user objects, __add__ and __radd__ may run arbitrary Python code.

So a general BINARY_OP + can’t simply emit left + right in C. It has to go through the object protocol.

CPython has a C table for numeric operations. In ceval.c, the binary operation table maps the add operation to PyNumber_Add:

[NB_ADD] = PyNumber_Add

You can think of PyNumber_Add as a dispatcher. Given two PyObject * values, it consults their types and slots to decide what “addition” means. A type object in CPython is a large C struct that contains metadata and function pointers. Those function pointers implement operations: deallocate this object, call this object, get an attribute, add this object to another, traverse references for the garbage collector, and so on.

A function pointer in C is exactly what the name says: a pointer to code. If a struct contains a function pointer, different instances of the struct can point at different functions. That is one of the C techniques CPython uses to implement dynamic behavior.

So the path for a + b is roughly:

bytecode says BINARY_OP +
  -> eval loop pops two PyObject* values
  -> generic numeric operation dispatcher
  -> inspect type objects and slots
  -> call the appropriate C function, or Python method, or raise TypeError
  -> push the resulting PyObject*

That flexibility is exactly where the overhead of naive Python arithmetic comes from. The runtime has to preserve Python semantics, and Python semantics aren’t “two int registers go into one CPU add instruction.”

I put a number on it. A real a + b on two integers, timed in a tight loop so the site is fully warmed up, costs about 13 nanoseconds on this machine, and the whole add(2, 3) including the call runs about 20:

add(2, 3) call:  ~20 ns
a + b (ints):    ~13 ns

A hardware add x0, x0, x1 is a fraction of one nanosecond. So even on the fast path, Python’s addition is more than an order of magnitude more expensive than the CPU instruction hiding at the bottom of it, and the whole gap is the object protocol: fetch two PyObject *, confirm they’re still exact integers, reach into their digit arrays, add, and hand back a pointer to an integer object. (These are noisy micro-timings, wobbling a nanosecond or two run to run, so read them as “about,” not as numbers you could bisect a regression against. When I timed the folded 2 + 3 it bounced between 4 and 8 ns across runs, which is mostly the timing harness’s own loop, not addition; the compiler had already turned that one into RETURN_CONST 5, so there was no add left to measure.)

But CPython 3.13 doesn’t always stay naive. Hot code specializes.

What the 2 actually is

That pretend stack said PyLong(2), and I owe the real version. What is physically sitting in the frame’s slot 0 when add runs? The CPython C API documentation says most API functions take or return PyObject *, that’s the runtime object model showing through, not just API style.

In the installed CPython 3.13 headers on my machine, object.h says, in the comment before the struct, that objects are always accessed through PyObject *. It also says the PyObject structure only contains the reference count and the type pointer, and that the actual allocated object has more data after that.

Simplified:

typedef struct _object {
    Py_ssize_t ob_refcnt;
    PyTypeObject *ob_type;
} PyObject;

Real 3.13 headers have conditional fields and unions for debug builds, tracing builds, and free-threaded builds. But if you are trying to understand the normal object model, the two fields above are the center:

  • ob_refcnt: how many strong references currently keep this object alive
  • ob_type: pointer to the object’s type object

The second field is the reason CPython can receive a PyObject * and later discover that it points to a list, integer, dict, class, function, or file object. The pointer doesn’t carry static C type information at runtime. The object header does.

CPython uses a C layout trick: every concrete object struct begins with the same header. For variable-sized objects, there is a related header that also stores size. The header macro appears in the installed headers:

#define PyObject_HEAD PyObject ob_base;

A list begins with the common object header, then adds list-specific fields. From cpython/listobject.h, simplified but structurally faithful:

typedef struct {
    PyObject_VAR_HEAD
    PyObject **ob_item;
    Py_ssize_t allocated;
} PyListObject;

PyObject_VAR_HEAD means “common object header plus a size field.” ob_item is a pointer to an array of PyObject * pointers. allocated is the capacity of that array.

So a Python list is one more level of indirection than it looks: a C object holding a pointer to a C array of pointers to Python objects.

For:

x = [10, "hi", None]

the layout is closer to:

PyListObject
  ob_refcnt
  ob_type  -> list type object
  ob_size  -> 3
  ob_item  -> [ PyObject* -> int 10,
                PyObject* -> str "hi",
                PyObject* -> None ]
  allocated -> maybe 4, maybe 3, implementation detail

Mixed-type lists fall out of this for free. The array never stores int then char * then a special null marker, every entry is a PyObject *.

A dict has a different layout. From cpython/dictobject.h, again simplified by leaving out many implementation details:

typedef struct {
    PyObject_HEAD
    Py_ssize_t ma_used;
    uint64_t ma_version_tag;
    PyDictKeysObject *ma_keys;
    PyDictValues *ma_values;
} PyDictObject;

Even before you understand the hash table: header first, dict-specific fields after.

An integer has yet another layout. Python’s int is arbitrary precision, not a C int. The installed longintrepr.h says long integers are made of 30-bit or 15-bit digits depending on the platform. On the normal 64-bit build I am using, each internal digit is 30 bits stored in a 32-bit type. The value is basically:

sum(ob_digit[i] * 2**(30*i))

plus sign and size metadata. Which is how Python gets away with:

10 ** 1000

without overflowing like a C long long. There’s no bigger CPU register involved, just a heap object storing multiple base-2^30 digits.

Python objects, then, are C structs, heap allocations, function pointers, arrays, and metadata. The model gets gnarly around descriptors, method binding, metaclasses, slots, weakrefs, and the layout differences between heap types and static types, none of which fits in this post.

The private heap under the objects

If Python objects are C structs, the next question is where those structs live.

The official C API docs describe Python memory management as a private heap containing Python objects and data structures, managed by the Python memory manager. That sounds abstract until you compare object sizes:

import sys

print(sys.getallocatedblocks())
print(sys.getsizeof([]))
print(sys.getsizeof([None]))
print(sys.getsizeof({}))
print(sys.getsizeof(0))
print(sys.getsizeof(10**100))

On this build:

23711
56
64
64
28
72

The exact numbers are build- and platform-dependent, but the pattern is useful. An empty list is already 56 bytes. A one-element list is 64 bytes. An empty dict is 64 bytes. The integer 0 is 28 bytes. A much larger integer, 10**100, is 72 bytes because it needs more internal digits.

Sit that next to add.py. The 5 my program computes is not a register or a 4-byte word, it’s a 28-byte heap object with a refcount, a type pointer, a size, and one 30-bit digit. This is the cost of “everything is an object”: each value carries headers, type information, refcount state, and type-specific layout.

CPython doesn’t usually call plain malloc for every tiny Python object in the naive way you might write on day one of C. It has allocator layers:

raw domain:
  low-level buffers, system allocator style

mem domain:
  Python private heap for general Python-managed memory

object domain:
  Python object allocation, usually through pymalloc

The default release-build allocator table in the Python docs lists pymalloc for the mem and object domains. pymalloc is CPython’s small-object allocator. Instead of asking the OS for every little object, CPython manages arenas, pools, and blocks so common object allocations are cheaper.

You don’t need the full arena/pool/block design to remember the rule that matters for extension code:

allocate and free with matching families

If memory came from PyObject_Malloc, it should go back through PyObject_Free. If it came from PyMem_Malloc, free it with PyMem_Free. If it came from C malloc, free it with C free. Mixing allocator families is undefined-behavior territory, and the Python docs warn extension writers not to operate on Python objects with raw C malloc and free.

The runtime, in other words, is C structs inside a memory-management policy. CPython wants to know about Python object memory so it can cache, debug, track, and in free-threaded builds enforce stricter allocation-domain rules.

The no-GIL work makes this more visible. The Python 3.13 memory docs say the free-threaded build requires Python objects to be allocated using the object domain. What used to be a best practice becomes a hard requirement because the runtime needs to know which allocations are Python objects when multiple threads can interact with the object system without a global lock.

Assignment moves a pointer

In Python:

a = []
b = a

doesn’t copy anything. b now points at the same list object as a.

You can watch the reference count move:

import sys

a = []
print("refcount a", sys.getrefcount(a))
b = a
print("after b=a", sys.getrefcount(a))
del b
print("after del b", sys.getrefcount(a))

My local Python 3.13 output:

refcount a 2
after b=a 3
after del b 2

Why does it start at 2 instead of 1? Because sys.getrefcount(a) temporarily receives a as an argument, and that argument is itself another reference while the function is running.

The picture:

a ----+
      |
      v
   PyListObject
      ^
      |
b ----+

After del b, the name b is removed and the reference count drops.

This is a core Python rule in C clothing:

b = a

means “bind another name to the same object.” CPython implements that by storing another pointer and bumping a reference count.

For immutable objects like small integers and strings, sharing is usually invisible:

x = 10
y = x

You don’t notice whether x and y point at the same integer object because you can’t mutate the integer object in place. x += 1 binds x to a different integer. It doesn’t edit the old 10.

Strings do the same sharing, but only for some strings, and the seam is worth seeing:

a = "hello"
b = "hello"
print(a is b)                              # True
print("hel" + "lo" is a)                   # True
print("".join(["h","e","l","l","o"]) is a) # False
print("".join(["h","e","l","l","o"]) == a) # True
True
True
False
True

Two "hello" literals are the same object, because the compiler interns short string constants, one shared copy in a table. "hel" + "lo" is also that same object, because the compiler folded it to "hello" before runtime and interned the result, the same constant-folding trick that turned 2 + 3 into RETURN_CONST 5. But "".join([...]) builds the string at runtime, letter by letter, and that one is a brand-new object that is equal to "hello" without being identical to it. is compares object identity, == compares value, and interning is the only reason they ever agree for strings you didn’t obviously share. (Interning shares the object; it doesn’t make it immortal. sys.getrefcount("hello") on this build reads a plain 5, not the 0xFFFFFFFF the small integers showed. Shared and undying are two different properties, and strings get the first without the second.)

For mutable objects, sharing is everything:

a = []
b = a
b.append(1)
print(a)  # [1]

That beginner Python behavior is the same object header and pointer model showing through.

Refcounting is the first garbage collector

CPython’s primary memory-management mechanism is reference counting.

Every object has a count. When CPython creates another strong reference to the object, the count goes up. When a strong reference goes away, the count goes down. When it reaches zero, the object can be destroyed immediately.

The installed object.h describes Py_INCREF and Py_DECREF as the normal way to increment and decrement reference counts. The interesting one is Py_DECREF, because it has to destroy the object if the count reaches zero.

In CPython 3.13 headers, the simplified normal-build logic is:

static inline void Py_DECREF(PyObject *op)
{
    if (_Py_IsImmortal(op)) {
        return;
    }
    if (--op->ob_refcnt == 0) {
        _Py_Dealloc(op);
    }
}

Decrement the count; if it hits zero, call the deallocator.

The deallocator comes from the type object. Lists know how to release their elements and free their item array. Dicts know how to release keys and values and free hash-table storage. Integers know how to free their integer storage. A user object knows how to release its instance dict, slots, weakrefs, and finalizer behavior. The common mechanism is refcount zero; the type-specific deallocator does the cleanup.

This has a very different feel from a tracing garbage collector like the one you might learn about in Java or Go. In many tracing collectors, garbage is found later by starting from roots and marking reachable objects. In CPython, most objects die at the exact moment their last reference goes away.

It’s the reason destruction often feels deterministic:

class Loud:
    def __del__(self):
        print("gone")

x = Loud()
del x

On CPython, gone usually prints immediately when del x removes the last reference. That’s a CPython consequence of refcounting, not a cross-implementation language guarantee.

It is also why C extension authors have to be so careful. If you own a reference, you must eventually Py_DECREF it. If you forget, you leak. If you decrement too many times, you may free an object that somebody else still expects to be alive. CPython’s C API gives you great power and very sharp accounting.

Cycles are the hole in refcounting

Reference counting has an obvious failure mode:

a = []
a.append(a)
del a

The list contains a reference to itself. Before del a:

a ---> list
       ^  |
       |__|

After del a, the name is gone, but the list still points to itself. Its reference count is not zero. No Python code can reach it anymore, but simple refcounting can’t prove that, because there is still one reference.

So CPython carries a second collector: the cyclic garbage collector.

The cyclic GC tracks container objects that can participate in cycles: lists, dicts, sets, user objects, and so on. Atomic objects like many integers and strings usually aren’t tracked because they can’t contain references to other objects.

You can see that from Python:

import gc

print(gc.is_tracked(0))
print(gc.is_tracked("a"))
print(gc.is_tracked([]))
print(gc.is_tracked({"a": []}))

On this build:

False
False
True
True

Atomic values generally aren’t tracked; containers generally are, with some type-specific optimizations.

The cyclic collector’s job is narrower than “free all memory.” Refcounting handles most object death. The GC handles the cases refcounting can’t: unreachable reference cycles.

My local small check:

import gc

c = []
c.append(c)
print("cycle tracked", gc.is_tracked(c))
del c
print("collect", gc.collect())

Output:

cycle tracked True
collect 45

Do not read too much into the number 45. A manual collection can pick up other trash already waiting in the process. The useful fact is that the self-referential list is tracked and collectable even though its refcount alone would not hit zero.

The GC relies on type support. Container types implement traversal functions so the collector can ask, “which other Python objects do you refer to?” In C API terms, that is the tp_traverse slot. Again, dynamic-language behavior becomes type-object function pointers.

The collector is generational in the default build. The gc docs describe three generations. New tracked objects begin in the youngest generation. If they survive collections, they move toward older generations. The runtime keeps allocation/deallocation counts and uses thresholds to decide when to collect.

Those thresholds are visible on this build:

import gc
print(gc.get_threshold())   # (2000, 10, 10)
print(gc.get_count())       # e.g. (1270, 4, 0)

The first number, 2000, is how far the count of allocations-minus-deallocations in the youngest generation is allowed to climb before a collection fires. (It used to be 700; 3.12 raised the young-generation threshold to 2000 to collect less often.) The two 10s govern how many younger collections happen before the next generation up gets swept. get_count() is the live running tally against those limits, the youngest at 1270 of its 2000 when I checked. Nothing here is exotic. It’s three counters and three thresholds deciding when the cleanup crew gets called.

The reason generational collection helps in many runtimes is the “young objects die young” observation. A lot of objects are temporary:

for line in file:
    parts = line.split(",")
    ...

parts may die every iteration. It is cheaper to check young objects frequently and old objects less frequently.

But CPython is a little unusual because reference counting already kills many young objects immediately. If a temporary list’s last reference disappears at the end of the loop body, Py_DECREF can destroy it right there. The cyclic collector doesn’t need to find it later.

So CPython’s cyclic GC is not the first line of defense. It is the cleanup crew for objects that are both:

tracked containers
unreachable from Python roots
kept alive by references inside the unreachable group

Finalizers complicate this. A __del__ method can run Python code while an object is being finalized. That code can even resurrect the object by storing self somewhere reachable again. CPython has years of careful behavior around finalization so cycles containing finalizers can usually be collected safely, but this is exactly where “just count references” stops being a complete explanation.

Weak references add another wrinkle. A weakref points at an object without keeping it alive. That is useful for caches and observer tables, but it means deallocation has to notify or clear weakrefs at the right moment.

So the real destruction path for a container object may involve:

refcount reaches zero
  -> type deallocator runs
  -> weakrefs cleared
  -> contained references decref'd
  -> finalizer behavior handled if relevant
  -> memory returned to allocator

or:

cyclic GC finds unreachable cycle
  -> finalization/traversal/clearing rules apply
  -> internal references are broken
  -> refcounts can drop to zero
  -> deallocators run

So CPython memory behavior manages to be deterministic and surprising at once. Simple objects often disappear immediately. Cycles wait for the collector. Objects with finalizers can have special timing. Objects referenced by global caches may never die until interpreter shutdown. Immortal objects may not behave like normal refcount examples at all. Generational thresholds, finalizer ordering, and the free-threaded build’s stop-the-world pauses go deeper than I’m going here.

Immortal objects make the obvious refcount demo strange

I tried this:

import sys

print(sys.getrefcount(42))
print(sys.getrefcount(None))
print(sys.getrefcount("abc"))

On this CPython 3.13 build:

4294967295
4294967295
4294967295

That is not a real count of four billion live references in my tiny process.

CPython has immortal objects. Some objects are marked so their refcount operations can be skipped or treated specially. The installed 3.13 object.h comments say that on 64-bit systems, immortal objects are marked by setting the lower 32 bits of the refcount to 0xFFFFFFFF. Decimal 4294967295 is exactly that value.

Why do this? Because certain objects are everywhere: None, booleans, small integers, some interned strings (strings CPython keeps a single shared copy of), and runtime singletons. Updating their refcounts constantly costs time and creates contention in multi-threaded designs. Immortalization lets CPython avoid a large class of refcount churn.

This is a good example of the danger in learning internals from one cute experiment. sys.getrefcount([]) teaches the normal refcount story. sys.getrefcount(42) on modern CPython teaches a newer optimization. Both are true, but only if you know which object you are looking at.

I poked at it a bit more, because a frozen counter is a testable claim: pile up a thousand references and the immortal number shouldn’t move, while a normal object’s count should.

import sys

big = 10**100
refs = [big] * 1000
print(sys.getrefcount(big))

refs2 = [42] * 1000
print(sys.getrefcount(42))

Output:

1002
4294967295

The big integer behaves like the textbook says: 1000 list slots, plus the name big, plus the temporary reference getrefcount itself holds while it runs, 1002. The 42 doesn’t budge. A thousand new references to it and the counter still reads 0xFFFFFFFF, because CPython skips refcount updates for immortal objects entirely. That’s the optimization made visible: the work saved isn’t hypothetical, it’s a thousand increments that never happened.

The immortal small integers stop somewhere, and I wanted the edge. The cache is documented as -5 through 256, so I pitted 256 against 257 and -5 against -6, forcing a freshly computed integer with int(str(v)) so I wasn’t just being handed the same literal back:

import sys
for v in [256, 257, -5, -6]:
    a = v
    b = int(str(v))
    print(v, a is b, sys.getrefcount(a))
256  True  4294967295
257  False 5
-5   True  4294967295
-6   False 5

The boundary sits exactly where the docs say. 256 and a separately computed 256 are the same object, immortal, refcount frozen at 0xFFFFFFFF. 257 and a separately computed 257 are two different objects with an ordinary, moving refcount of 5. The line between “shared singleton that never dies” and “normal heap integer that lives and dies by its count” runs between 256 and 257, and my add.py result, 5, sits comfortably on the immortal side of it. If the program had printed add(200, 100) instead, the 300 would have been a real allocation with a real refcount, born when the addition ran and freed when print let go of it.

And it answers the question I opened the post with, who deletes the 5? Nobody:

def add(a, b):
    return a + b

x = add(2, 3)
y = 5
print("same object as literal 5:", x is y)
print("refcount of the result:", sys.getrefcount(x))
same object as literal 5: True
refcount of the result: 4294967295

My program didn’t even create its result. Small integers (-5 through 256) are preallocated singletons in the runtime, immortal since 3.12, and BINARY_OP handed me a pointer to the same 5 that every other program on this interpreter uses. The value add.py computes and prints is older than the program itself, it was born during interpreter startup, before my file was read, and it will never die. LOAD_CONST doesn’t build a 2; the compiler’s constant already points at the shared one. The whole arithmetic life of my five lines happened without a single object being allocated or freed.

Except I wanted to prove “without a single object being allocated,” and when I actually counted, it didn’t come out to zero. sys.getallocatedblocks() reports how many pymalloc blocks are live right now, so I sampled it around one warmed-up call, with the cyclic GC switched off so it couldn’t muddy the count:

import sys, gc
gc.disable()
def add(a, b): return a + b
for _ in range(5): add(2, 3)      # warm up
before = sys.getallocatedblocks()
add(2, 3)
after = sys.getallocatedblocks()
print(after - before)
1

One block, not zero. For a minute I thought the integers were lying to me and there was a hidden allocation in the addition after all. So I ran a thousand calls instead of one:

1000 calls: block delta = 2

Two blocks for a thousand calls, not a thousand blocks. So the addition itself allocates nothing, exactly as the immortal 5 promised; the tiny nonzero drift is the call machinery, not the arithmetic. CPython carves the interpreter’s data stack out of memory in chunks, and once in a great while a call needs that chunk to grow, which surfaces as a block or two amortized across many calls. The honest version of my sentence is narrower than the triumphant one I wanted to write: the values my five lines compute cost zero allocations, because every one of them was born before the program started. The calls occasionally nudge a stack chunk. Nobody, anywhere in the run, allocates a 2, a 3, or a 5.

Exceptions are state the C code keeps checking

Feed add operands that don’t add:

add(2, "3")
  File "<string>", line 3, in add
    return a + b
           ~~^~~
TypeError: unsupported operand type(s) for +: 'int' and 'str'

As a Python programmer, the mental model “the TypeError flies upward until something catches it” is fine.

Inside CPython C code, the common convention is more mechanical:

set exception state
return an error indicator
caller sees the error indicator
caller cleans up what it owns
caller returns its own error indicator
...
eventually Python-level handler or top-level traceback machinery handles it

For functions returning PyObject *, the error indicator is usually NULL. For functions returning int, it is often -1. For functions returning pointers to internal data, it depends on the API. The important part is that CPython C code usually doesn’t throw a C++ exception through the runtime. It sets Python exception state, then uses C return values to unwind.

So extension code looks like this:

PyObject *x = PyLong_FromLong(123);
if (x == NULL) {
    return NULL;
}

PyLong_FromLong can fail if allocation fails. If it fails, it sets a Python exception, likely MemoryError, and returns NULL. The caller shouldn’t invent a new error if one is already set. It should clean up owned references and return NULL too.

This “set exception state, return sentinel, clean up owned references” discipline is everywhere in CPython. It’s also one of the reasons source code in the runtime has so many goto error; or goto fail; paths. In C, a single cleanup label is often the least bad way to avoid leaking half-built objects when step 7 of 11 fails.

The eval loop participates in this too. That add(2, "3") traceback above is the discipline working end to end: PyNumber_Add found no way to add an int to a str, so it set a TypeError and returned an error indicator. The eval loop unwinds the current bytecode operation, notices the exception, and transfers control to the appropriate exception handler if the frame has one. If no handler exists in the frame, the frame returns an error to its caller. Eventually, if nobody catches it, the top-level runtime prints the traceback.

Tracebacks are objects as well. Exception types are objects. Exception instances are objects. The stack frames mentioned in a traceback correspond to Python frame state. Even “an error happened” is made of objects.

This matters because it keeps the C API’s rules legible:

return PyObject*:
  non-NULL -> a Python object result
  NULL     -> exception is set

return int:
  0 or positive -> success, depending on API
  -1            -> error, exception is usually set

When you see if (result == NULL) return NULL; in CPython or an extension module, you are watching Python exception propagation in C grammar.

The GIL is the old bargain

The Global Interpreter Lock is usually introduced as “the thing that prevents Python threads from running in parallel.” That is directionally true for CPU-bound Python bytecode on normal CPython builds, but it skips the reason the lock was so convenient in the first place.

CPython’s default object model mutates shared runtime state constantly:

  • increment this object’s refcount
  • decrement that object’s refcount
  • resize this dict
  • append to this list
  • update an inline cache
  • allocate or free an object
  • check and set exception state

If two OS threads could execute Python bytecode at the same time inside the same interpreter, those operations would need to be thread-safe. You could put locks around every object. You could use atomic increments for refcounts. You could redesign containers. You could make allocators cooperate. You could do all of that, but it would complicate the runtime and slow down common single-threaded code.

The GIL is the older CPython bargain: one thread at a time runs Python bytecode in an interpreter. With that rule, much of the C runtime can assume it isn’t being concurrently mutated by another Python thread.

On my local 3.13 build:

import sys

print(hasattr(sys, "_is_gil_enabled"))
print(sys._is_gil_enabled())

Output:

True
True

This doesn’t mean threads are useless in Python. Threads can overlap I/O. C extensions can release the GIL around long-running native work. Libraries like NumPy can do heavy computation in C or Fortran outside normal Python bytecode execution. But if you write two CPU-bound loops in pure Python threads on the default CPython build, the GIL is the reason they do not simply burn two cores executing Python bytecode simultaneously.

I’d read that sentence a hundred times before I bothered to time it. Ten cores on this M4, two CPU-bound Python threads, against the identical work run sequentially:

import time, threading

def count(n):
    x = 0
    while x < n:
        x += 1

N = 40_000_000
t0 = time.perf_counter(); count(N); count(N)
seq = time.perf_counter() - t0

t0 = time.perf_counter()
ts = [threading.Thread(target=count, args=(N,)) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
par = time.perf_counter() - t0

print(f"sequential {seq:.3f}s, threaded {par:.3f}s, speedup {seq/par:.2f}x")
sequential 1.171s, threaded 1.162s, speedup 1.01x

Ten cores, and threading bought me one percent. Not two times, not even 1.2x. The two threads took turns holding the one GIL, and the only thing “parallel” about them was the overhead of handing the lock back and forth. That’s the bargain read off a stopwatch: on the default build, threading a pure-Python compute loop does not make it faster, and the M4’s nine idle cores got to watch it not happen. Swap threading for multiprocessing and the number changes, because separate processes have separate interpreters and separate GILs, but that’s a different lock per process, not shared parallelism inside one.

The important part for this post is how deeply the GIL connects to the object model. Refcounting with plain increments and decrements is much easier under a giant interpreter lock. Dict and list fast paths are much easier under a giant interpreter lock. The C API grew up in a world where extension authors could often rely on the GIL being held while they touched Python objects.

Removing the GIL was never going to be a one-line patch called delete lock.

Free-threading is a separate build

Python 3.13 introduced experimental support for running CPython with the GIL disabled. This comes from PEP 703.

The official Python 3.13 docs are careful about the status: free-threaded CPython is experimental, the GIL-disabled mode isn’t the default, and you usually need a separate executable named something like python3.13t or a source build configured with --disable-gil.

I checked my Homebrew install:

$ python3.13 -c "import sysconfig,sys; print(sysconfig.get_config_var('Py_GIL_DISABLED')); print(sys._is_gil_enabled())"
0
True

The build was compiled with the GIL, and the GIL is on, and there is no local python3.13t in this install. So I cannot honestly show a local no-GIL run here.

But the installed headers already show traces of the design. In a normal build, the object header is essentially refcount plus type pointer. In a Py_GIL_DISABLED build, object.h has a different layout that includes a per-object lock:

PyMutex ob_mutex; // per-object lock

And cpython/lock.h defines PyMutex as a one-byte mutex:

typedef struct PyMutex {
    uint8_t _bits;  // private
} PyMutex;

The two least significant bits encode unlocked, locked, and whether parked threads exist. That doesn’t mean every object operation simply grabs this lock all the time; the no-GIL design is more nuanced than “put a mutex in every object and hope.” PEP 703 discusses biased reference counting, immortalization, container locking, allocator changes, and optimistic access paths for dicts and lists.

The high-level split:

default CPython:
  one interpreter-level lock protects many runtime assumptions

free-threaded CPython:
  runtime data structures need their own thread-safety story
  reference counting cannot assume only one bytecode thread mutates counts
  extension modules must declare whether they are safe without the GIL

That last point is easy to miss: the C API is an ecosystem contract. Thousands of extension modules were written assuming the GIL exists. If a free-threaded build imports an extension that has not declared GIL-free support, Python 3.13 can enable the GIL for safety unless you override it. The docs mention the Py_mod_gil slot for multi-phase module initialization and PyUnstable_Module_SetGIL() for single-phase initialization.

So free-threading touches everything:

  • object headers
  • refcount operations
  • containers
  • memory allocation
  • cyclic GC pauses
  • specialization safety
  • extension-module declarations
  • the practical performance profile of single-threaded code

PEP 703 even notes that in a no-GIL build, the specializing interpreter has to avoid concurrent specialization of the same bytecode. It uses synchronization so two threads don’t rewrite interpreter state under each other.

Specialization: the bytecode changes after it learns

Fresh from the compiler, add disassembles the way it did at the start:

  1           RESUME                   0

  2           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

Then I called it a lot:

for _ in range(20_000):
    add(2, 3)

and disassembled with adaptive details:

dis.dis(add, adaptive=True, show_caches=True)

Output:

  3           RESUME_CHECK             0

  4           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP_ADD_INT        0 (+)
              CACHE                    0 (counter: 832)
              RETURN_VALUE

BINARY_OP became BINARY_OP_ADD_INT.

That is the specializing adaptive interpreter. CPython notices that this operation has been seeing exact integers, so it replaces the general operation with a more specific one. The specific opcode can skip much of the generic dynamic dispatch. It can say, in effect, “I have seen enough int + int here to try the integer fast path first.”

This is not the same as a JIT. No machine code was generated in this local run. The interpreter is still executing bytecode. But the bytecode has become more precise, and it carries inline cache data next to the instruction.

The cache line in the disassembly:

CACHE                    0 (counter: 832)

is CPython storing runtime feedback near the instruction. Different opcodes use caches differently: attribute access, method calls, globals, binary operations, and so on. The general idea is the same as many dynamic-language runtimes: if a site keeps seeing the same shape of operation, remember enough to make the next time cheaper.

This also explains the weird fused opcode LOAD_FAST_LOAD_FAST. CPython 3.13 has opcodes that combine common instruction pairs. Loading two locals in one instruction is cheaper than dispatching two separate instructions. Dispatch overhead matters in an interpreter; every bytecode instruction means the C eval loop has to do another step.

I tried to catch the specialization flip on a stopwatch and couldn’t. BINARY_OP rewrites itself within the first handful of calls, so by the time a perf_counter loop is long enough to beat timer noise on this machine, the function is already specialized. The first 200-call batch does run a few nanoseconds per call slower than the rest, about 23-27 ns against a steady 21, but I can’t honestly attribute that to specialization rather than cold caches, so no before/after benchmark here.

“Handful” bugged me, so I went and counted properly. Instead of timing, I built a fresh add for each trial, called it exactly N times, and asked dis whether the BINARY_OP had turned into BINARY_OP_ADD_INT yet:

def make_add():
    def add(a, b):
        return a + b
    return add

def is_specialized(fn):
    return any(i.opname == "BINARY_OP_ADD_INT"
               for i in dis.get_instructions(fn, adaptive=True))

for n in range(6):
    add = make_add()
    for _ in range(n):
        add(2, 3)
    print(n, is_specialized(add))
0 False
1 False
2 True

Two. Not a handful, two. The site is generic on its first call, still generic on its second, and specialized before a third would run. That’s why the stopwatch never caught it: by the time any timing loop is long enough to out-shout timer noise, the flip already happened, back in its first microsecond of life.

Then I tried to break it, which taught me more than the threshold did. I warmed add on integers until it read BINARY_OP_ADD_INT, then started feeding it strings and watched the opcode after every single call:

for _ in range(10): add(2, 3)      # -> BINARY_OP_ADD_INT
for i in range(1, 60):
    add("x", "y")
    # inspect which BINARY_OP variant is live now

I expected it to notice the guard failing and drop back to the generic BINARY_OP. It didn’t. It stayed BINARY_OP_ADD_INT for fifty-two more calls, absorbing the wrong-type guard miss each time, and on the fifty-third call it flipped straight to BINARY_OP_ADD_UNICODE. Not back to generic and then re-adapting from scratch. Straight from one specialized form to the other. Feed it integers again and it flips back to BINARY_OP_ADD_INT; a function that only ever sees floats specializes a third way:

after int warmup:      BINARY_OP_ADD_INT
after 53 string calls: BINARY_OP_ADD_UNICODE
back to ints:          BINARY_OP_ADD_INT
float-only function:   BINARY_OP_ADD_FLOAT

That fifty-three is the inline cache’s backoff counter draining. A single wrong-type call doesn’t cost the specialization; the runtime treats one miss as a blip and keeps betting on integers. Only a sustained change of type wears the counter down far enough to re-specialize. I didn’t reverse-engineer the exact arithmetic that lands it on 53 rather than 50 or 64, so I’m holding that number as measured, not derived. But the shape is unmistakable, and it’s the CACHE (counter: 832) line from the disassembly doing its actual job: that counter is a running confidence score in the current guess, and the opcode only changes shape once confidence runs out.

The eval loop in 3.13, then, is an adaptive runtime rather than a naive switch frozen in time:

initial bytecode:
  general enough to be correct

after runtime feedback:
  specialized enough to be faster when assumptions hold

if assumptions break:
  deoptimize or fall back to general behavior

For example, if that same addition site starts seeing strings or user objects, CPython can’t keep pretending it is always int + int. Correctness wins. The runtime has to preserve Python semantics even while it chases speed.

This is the part of CPython performance work I find easiest to underestimate. The interpreter is full of small bets: this name lookup probably hits the same globals dict; this attribute probably comes from the same type; this addition probably remains exact integers. The runtime makes the bet, caches the path, and keeps enough bookkeeping to retreat when the bet fails.

The experimental JIT in 3.13 is another tier

Python 3.13 also includes an experimental JIT compiler, from PEP 744.

Checking whether my build has it took two tries, because my first proof was a tautology. I ran hasattr(sys, "_jit"), got False, and wrote “this build wasn’t compiled with the JIT”, which happens to be true, but the check proves nothing: sys._jit doesn’t exist in any 3.13, JIT or not. It was added in 3.14. A 3.13 built with the JIT enabled would have printed False too. I’d tested for a dashboard that hadn’t been invented yet and read its absence as a fact about the engine. The real check asks the build configuration:

$ python3.13 -c "import sysconfig; print('_Py_JIT' in (sysconfig.get_config_var('PY_CORE_CFLAGS') or ''))"
False

_Py_JIT lands in the core CFLAGS when the JIT is compiled in. This Homebrew build wasn’t built with it.

And since this machine also has a Homebrew 3.14, I asked the version that does have the dashboard:

$ python3.14 -c "import sys; print(sys._jit.is_available(), sys._jit.is_enabled())"
False False

is_available: False, Homebrew builds 3.14 without the JIT too. So: two interpreters, a real introspection module in the newer one, and no JIT engine in either. Everything below is architecture, not measurement.

The official 3.13 docs describe how to build it:

--enable-experimental-jit

and how the architecture fits together (IR is intermediate representation, instructions simpler than bytecode that an optimizer can chew on):

specialized Tier 1 bytecode
  -> hot enough code moves to Tier 2 IR, also called micro-ops
  -> optimization passes run on that IR
  -> optimized Tier 2 IR can be translated to machine code

The machine-code generation strategy is called copy-and-patch. The docs note that the JIT has a build-time LLVM dependency, but no runtime dependency.

This is easy to muddle with specialization. The distinction:

specializing interpreter:
  still an interpreter
  still executing bytecode
  replaces generic bytecode with more specific bytecode
  uses inline caches and runtime feedback

experimental JIT:
  optional build-time feature in 3.13
  takes hot optimized internal IR
  emits machine code for selected paths

The JIT sits above the specializing interpreter. It doesn’t replace the whole runtime with compiled machine code. Python is too dynamic, and the C API is too important, for “JIT means Python becomes C speed” to be true.

Even in a JIT-enabled build, Python still has objects, refcounts, exceptions, imports, frames, C extension boundaries, and fallback paths. The JIT can accelerate hot regions where the runtime has enough information. The interpreter remains the semantic backstop.

Calls are a runtime protocol too

The module disassembly back at the start had two call instructions, CALL 2 for add(2, 3), then CALL 1 handing the result to print, and I’ve been treating them as obvious ever since. They aren’t. A Python call is one of the most important operations in the runtime, and it isn’t simply a C call to a known address, because the thing being called might be a Python function like add, a built-in C function like print, a bound method, a class constructing an instance, an object with __call__, a descriptor result (descriptors are objects whose attribute access runs code, property is one), a functools.partial, or something stranger supplied by an extension module. My one line of source exercised two different cases without telling me. The syntax is one thing; the callable object decides the path.

In the older mental model, a call creates a tuple for positional arguments and a dict for keyword arguments, then passes those containers to the callable. That model is easy to understand and often too expensive. If every tiny call had to allocate a tuple and dict just to pass two arguments, call overhead would dominate even more than it already does.

Modern CPython uses a faster calling convention called vectorcall for many callables. You can see the flag in the installed object.h:

#define Py_TPFLAGS_HAVE_VECTORCALL (1UL << 11)

The idea isn’t mysterious: pass arguments as a C array of PyObject * pointers, plus counts and flags, instead of always packaging them into Python tuple/dict objects first.

Cartoon:

PyObject *args[2] = {arg0, arg1};
result = callable_vectorcall(callable, args, 2, kwnames);

The real protocol has details for keyword names, bound methods, and type slots, but the performance reason is plain: a hot call like len(x) or obj.method(a, b) shouldn’t have to build a tuple to pass two arguments.

A Python function call still has to create or initialize a frame. A C built-in call may jump to a C function with parsed arguments. A method call has to bind or avoid binding self depending on the path. A class call may run __new__ and __init__. But vectorcall removes a layer of avoidable object churn from the common path. It doesn’t make Python statically typed or remove the object protocol, it looks at a dynamic operation and asks which part of it is pure overhead even under Python’s rules. For calls, the answer was allocating argument containers when the callee can consume a pointer array directly.

Method calls and attribute access got the same kind of attention because a line like:

total += item.cost()

is roughly:

load total
load item
load attribute cost
maybe bind method
call it
add result to total
store total

Each step has possible user-defined behavior, item.__getattribute__ could run Python code, cost might be a descriptor, += might call in-place add or normal add. The interpreter has to preserve all of that.

Optimization in CPython often means carving out the common boring cases without breaking the weird legal ones:

ordinary object, ordinary type, ordinary method, stable dict version:
  fast path

custom descriptor, mutated class, monkey-patched method, changed globals:
  guard fails, use the general path

That is why dicts have version tags. That is why type objects carry flags. That is why inline caches sit next to bytecode. The runtime is constantly asking whether the world still looks like it did last time.

If it does, go fast. If it does not, be Python.

The C API is why the runtime cannot just become something else

The Python/C API lets C and C++ code talk to Python objects and the interpreter. The official docs put it plainly: the API gives C and C++ programmers access to the Python interpreter, either to write extension modules or to embed Python in larger applications.

That API is how CPython became the center of a huge native-extension ecosystem. NumPy, SciPy, pandas, PyTorch, Pillow, lxml, cryptography, database drivers, compression modules, GUI bindings: a lot of “Python” performance comes from Python orchestrating native code.

The C API operates in the same world we have been building:

#define PY_SSIZE_T_CLEAN
#include <Python.h>

static PyObject *
add_one(PyObject *self, PyObject *arg)
{
    long x = PyLong_AsLong(arg);
    if (x == -1 && PyErr_Occurred()) {
        return NULL;
    }
    return PyLong_FromLong(x + 1);
}

That tiny function receives a PyObject *. It asks CPython to convert it to a C long. If conversion fails, CPython sets an exception and the function returns NULL. If conversion succeeds, it creates a new Python integer and returns it.

In C extension code, NULL does not mean Python None. It usually means “an exception is set; unwind.” Returning Python None means returning a real PyObject * pointing at the singleton None, usually with a macro like Py_RETURN_NONE.

The reference ownership rules matter. PyLong_FromLong returns a new reference. The caller now owns that reference. Returning it to Python transfers the responsibility to the runtime’s calling convention. If a C function creates an object and then decides to fail, it must decref what it owns before returning NULL.

The C API docs phrase this in terms of ownership of references, not ownership of objects. Objects are shared. References are what you own or borrow.

That distinction took me longer than it should have:

owning a reference:
  you are responsible for eventually releasing it with Py_DECREF

borrowing a reference:
  you may use it while the owner keeps it alive
  you must not Py_DECREF it just because you looked at it

stealing a reference:
  you pass a reference to a function
  that function takes over your ownership responsibility

The same object can have many references. Saying “I own a reference” does not mean “I own the object and nobody else can use it.” It means “the refcount includes my claim, and I must later remove my claim.”

Here is the trap in miniature:

PyObject *item = PyList_GetItem(list, 0);      // borrowed reference
PyObject *same = PySequence_GetItem(list, 0);  // new reference

Both can retrieve the same Python object from the same Python list. But the ownership rule is different because the functions are different. PyList_GetItem gives you a borrowed reference. PySequence_GetItem gives you a new reference. The type of the returned object does not decide whether you own it. The API function decides.

So:

PyObject *item = PyList_GetItem(list, 0);
if (item == NULL) {
    return NULL;
}
/* use item */
/* no Py_DECREF(item) */

but:

PyObject *item = PySequence_GetItem(list, 0);
if (item == NULL) {
    return NULL;
}
/* use item */
Py_DECREF(item);

The C API has reference-stealing calls too. PyTuple_SetItem and PyList_SetItem are the famous ones. They were designed for the common pattern where you create a fresh object and immediately put it into a brand-new container:

PyObject *t = PyTuple_New(3);
if (t == NULL) {
    return NULL;
}

PyTuple_SetItem(t, 0, PyLong_FromLong(1));
PyTuple_SetItem(t, 1, PyLong_FromLong(2));
PyTuple_SetItem(t, 2, PyUnicode_FromString("three"));

The new references returned by PyLong_FromLong and PyUnicode_FromString are stolen by the tuple. That means the tuple now owns them. Without stealing, the code would need temporary variables and decrefs after every successful insertion. With stealing, the success path is shorter, but the rule is sharp enough to draw blood if you forget it. (And yes, this snippet hands PyTuple_SetItem the results of PyLong_FromLong unchecked, right after a whole section preaching error paths. If an allocation fails there, a NULL goes into the tuple and nothing notices until later. Real code checks each one.)

CPython C feels less like “Python with braces” and more like systems programming. You’re juggling:

object pointer
exception state
new/borrowed/stolen reference ownership
GIL assumptions
type slots
allocator domains

And all of that is just to make a function callable from Python.

A slightly more complete extension skeleton looks like:

static PyMethodDef methods[] = {
    {"add_one", add_one, METH_O, "add one to an integer"},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "demo",
    NULL,
    -1,
    methods
};

PyMODINIT_FUNC
PyInit_demo(void)
{
    return PyModule_Create(&module);
}

This isn’t pseudocode the way the eval-loop cartoon was pseudocode. It’s the rough shape of a real C extension. Build it correctly, import it, and Python can call demo.add_one(41).

What happens when Python calls it?

Python call site
  -> eval loop sees a call
  -> callable object points at extension function metadata
  -> CPython converts the call into a C function call
  -> C receives PyObject* arguments
  -> C uses Python/C API to inspect/create/manipulate objects
  -> C returns PyObject* or NULL-with-exception
  -> eval loop continues

This is the power and the constraint. The C API gives Python its native ecosystem. It also freezes a lot of assumptions into public or semi-public behavior. If an extension expects object layout, refcount semantics, the GIL, borrowed-reference lifetimes, or certain macros to work a particular way, CPython has to move carefully.

The specializing interpreter and the JIT are shaped around this compatibility. CPython can’t simply decide that all integers are unboxed (raw machine values with no PyObject header) everywhere, because a C extension can receive a PyObject * and expect a real Python object. It can’t freely move objects around in memory like some compacting GCs do, because C code may hold raw object pointers. It can’t remove refcounting overnight, because the C API’s ownership model is refcount-shaped.

What I still haven’t run

Everything in this post about the free-threaded build came out of header files. The ob_mutex byte, the PyMutex bit encoding, the allocator-domain rule, read, not executed, because this install has no python3.13t. I checked twice: no python3.13t, no python3.14t, and Homebrew’s four Python kegs on this machine are all plain GIL builds. So the one number I most want, a real speedup from those two CPU-bound threads on a runtime without the lock, I can’t produce here. The 1.01x stands as the last honest reading I have.

That’s the open thread: install a free-threaded build, point nm at its library, and re-run every demo in this post against a runtime that no longer assumes one bytecode thread at a time. I want the thread benchmark to come back as something near 2x on this ten-core chip, and I want to watch it happen instead of asserting it. I expect the refcount numbers to come back different too, PEP 703 splits the count into a biased local part and a shared part, so sys.getrefcount may not even mean the same thing it meant in the demos above. And I want to find out whether ob_mutex earns its byte in ways I can measure rather than just read. I haven’t done it yet.

Sources and artifacts

The local artifacts in this post came from Homebrew Python 3.13.5 on my machine (plus one probe against Homebrew 3.14.5 in the JIT section):

/opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3.13
/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/Python
/private/tmp/cpython-notes/add.py   (the five lines everything else is about)

The CPython and Python documentation sources I leaned on: