AnyLearn
All lessons
Programmingadvanced

CPython: bytecode and the interpreter loop

What actually runs when you run Python: the compiler turns source into a code object of bytecode, and a single C evaluation loop executes it on a stack machine, one frame per call. Covers code objects, the dis module, the value stack, frames, and the adaptive specializing interpreter.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 9

From source to a code object

Running Python is two phases. First the compiler turns source into bytecode, a flat sequence of instructions for a virtual machine. Then the interpreter executes that bytecode. The compile step is not hidden; you can trigger it directly.

src = 'x = a + b'
code = compile(src, '<demo>', 'exec')
type(code)        # <class 'code'>
code.co_code      # raw bytes of the instructions

A code object is the compiled unit for one module, function, or comprehension. Functions carry theirs on func.__code__. Bytecode is cached to .pyc files under __pycache__ so the compile step is skipped next time, but execution is identical whether the bytecode came from source or a cached file. CPython is a bytecode interpreter, not a machine-code compiler: there is no ahead-of-time step to native code.

Full lesson text

All 9 steps on one page, for reading, reference, and search.

Show

1. From source to a code object

Running Python is two phases. First the compiler turns source into bytecode, a flat sequence of instructions for a virtual machine. Then the interpreter executes that bytecode. The compile step is not hidden; you can trigger it directly.

src = 'x = a + b'
code = compile(src, '<demo>', 'exec')
type(code)        # <class 'code'>
code.co_code      # raw bytes of the instructions

A code object is the compiled unit for one module, function, or comprehension. Functions carry theirs on func.__code__. Bytecode is cached to .pyc files under __pycache__ so the compile step is skipped next time, but execution is identical whether the bytecode came from source or a cached file. CPython is a bytecode interpreter, not a machine-code compiler: there is no ahead-of-time step to native code.

2. Inside a code object

A code object bundles everything needed to run one block, with constants and names pulled into side tables so instructions can reference them by index.

def f(a, b):
    c = a + b
    return c * 2

f.__code__.co_varnames   # ('a', 'b', 'c')  local slots
f.__code__.co_consts     # (None, 2)        literals
f.__code__.co_names      # ()               global/attr names
f.__code__.co_argcount   # 2

Instructions do not embed values; they embed indices. LOAD_CONST 1 means push co_consts[1]; LOAD_FAST 0 means push local slot 0. Locals live in a fixed-size array indexed by position, which is why local access is fast and why you cannot add a local to a running frame. Globals and attributes go through co_names and a dict lookup, which is measurably slower, the reason hoisting a global into a local speeds up hot loops.

3. Reading disassembly with dis

The dis module prints bytecode as human-readable instructions. Reach for it whenever you want to know what Python actually does.

import dis
def f(a, b):
    return a + b

dis.dis(f)
#   LOAD_FAST     0 (a)
#   LOAD_FAST     1 (b)
#   BINARY_OP     0 (+)
#   RETURN_VALUE

Each line is one instruction: an opcode plus an optional argument (here the local's index, with its name shown in parentheses). Read top to bottom: push a, push b, pop both and push their sum, return the top of the stack. dis is the ground truth for questions like 'is this comprehension faster' or 'does this line trigger an attribute lookup', because it shows the exact instructions rather than your mental model of them.

4. A stack machine

CPython's virtual machine is stack-based: instructions push and pop operands on a per-call value stack rather than naming registers. Evaluating a + b is three moves.

LOAD_FAST  a   # stack: [a]
LOAD_FAST  b   # stack: [a, b]
BINARY_OP  +   # pop b, pop a, push a+b  ->  [a+b]

Every opcode has a fixed effect on stack depth, and the compiler pre-computes the maximum depth a code object can reach (co_stacksize) so the stack can be one fixed array. Note that BINARY_OP does not know it is adding integers: it dispatches to the left operand's __add__ (falling back to the right operand's __radd__). So even a + b is a dynamic dispatch through the data model, which is exactly the cost the adaptive interpreter later attacks.

5. Frames: the unit of execution state

Each active call gets a frame holding its execution state: the code object, the local array, the value stack, and the instruction pointer (f_lasti). Calling a function pushes a frame; returning pops it. The chain of frames is the call stack you see in a traceback.

import sys
def g():
    fr = sys._getframe()
    return fr.f_code.co_name, fr.f_back.f_code.co_name

g()   # ('g', name of the caller)

f_back points at the caller's frame, which is how tracebacks walk upward. Since Python 3.11, frames for pure-Python calls live in a contiguous stack chunk rather than as separate heap objects, cutting call overhead substantially; a full frame object is materialized only when something (a traceback, sys._getframe, a debugger) actually asks for one.

6. The evaluation loop

At the heart of CPython is one C function, _PyEval_EvalFrameDefault, often called ceval. It runs a frame by looping: fetch the next instruction, dispatch to the handler for that opcode, repeat until the frame returns.

for (;;) {
    opcode = NEXT_OPCODE();
    switch (opcode) {
        case LOAD_FAST:    /* push a local */         break;
        case BINARY_OP:    /* pop 2, push result */   break;
        case RETURN_VALUE: /* pop and return it */    return top;
        /* one case per opcode */
    }
}

On compilers that support it the switch becomes computed gotos (a threaded interpreter): each opcode jumps straight to the next instead of routing through one shared branch, which helps the CPU branch predictor. This single loop is where every line of Python ultimately executes.

7. The adaptive specializing interpreter

Because every operation is dynamic, CPython 3.11 added a specializing adaptive interpreter (PEP 659). It watches what an instruction actually does at runtime, then rewrites it in place to a faster specialized form, with an inline cache storing the assumptions.

A generic BINARY_OP that keeps seeing two ints specializes to an add-int form that skips the __add__ dispatch and adds directly, guarded by a type check. If the guard later fails (a str appears), it de-specializes back to the generic instruction. This in-place rewriting is called quickening, and it happens only after an instruction has run enough times to be worth it.

The published goal was speedups up to about 50 percent on the release's benchmarks. It is transparent: your source, your bytecode as dis shows it, and your semantics are unchanged; only the interpreter's internal fast paths differ.

8. Bytecode is an implementation detail

The bytecode is not a stable API. Opcodes are added, removed, and renumbered between minor versions: BINARY_ADD became BINARY_OP in 3.11, adaptive opcodes appeared, and .pyc files are version-tagged so a mismatched interpreter recompiles rather than trusts them.

stable across versionsnot stable
the language and data modelthe opcode set and their numbers
the dis module itselfthe exact instructions it prints
most code object attributesthe co_code byte layout

So use dis to understand and profile code, never to depend on a particular instruction sequence. Tools that rewrite bytecode directly (some coverage, hot-patching, and debugging libraries) must special-case each Python version, which is why they tend to break on a new release before ordinary pure-Python code does.

9. From source to execution

The path a line of Python takes: compiled once into a code object, optionally cached, then run by the evaluation loop, whose adaptive layer rewrites hot instructions on the fly.

flowchart LR
  A["source .py"] --> B["compiler"]
  B --> C["code object: bytecode plus tables"]
  C --> D["cached to .pyc"]
  C --> E["ceval loop runs the frame"]
  E --> F["adaptive layer specializes hot opcodes"]
  F --> E

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What does `compile('x = a + b', '<s>', 'exec')` return?
    • A native machine-code function ready to call
    • A string containing assembly
    • A code object holding bytecode and side tables
    • None, because it executes the statement immediately
  2. In the bytecode for `a + b`, what does the `BINARY_OP` instruction actually do?
    • Adds two C integers directly, in every case
    • Pops two operands and dispatches through the data model, calling __add__
    • Concatenates the operands' raw bytes
    • Loads a and b from the globals dictionary
  3. When the adaptive interpreter (PEP 659) 'quickens' a hot instruction, it is:
    • Compiling the whole function to native machine code
    • Caching the function's return value for reuse
    • Skipping the instruction whenever possible
    • Rewriting a generic opcode into a specialized form guarded by a type check
  4. Why can't you add a brand-new local variable to an already-running frame?
    • Locals live in a fixed-size array indexed by position, sized at compile time
    • The frame object is completely immutable
    • Local names are interned and therefore frozen
    • Adding one would deadlock the GIL
  5. You want to know whether a list comprehension triggers an attribute lookup. Best tool?
    • timeit alone, then guess from the timing
    • Reading the raw .pyc bytes by hand
    • dis.dis, which prints the actual instructions the interpreter runs
    • sys.getrefcount on the comprehension

Related lessons