Expert · Lesson 80 of 94

How CPython runs your code

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Python looks like it runs your file line by line, but there is a step in between. When you run python app.py, CPython reads the whole file, parses it into a syntax tree, compiles that tree into bytecode, and only then starts executing. Bytecode is a compact sequence of instructions for a stack-based virtual machine: load this name, call that function, store the result. The evaluation loop inside the interpreter reads one instruction at a time and performs it. Understanding this pipeline explains why a SyntaxError appears before any output, why imports have a cost, and what tools like dis are showing you.

How CPython runs your code: app.py source → Bytecode: code objects → Evaluation loop → Output
How CPython runs your code · Figure 1 · Open for full size

The dis module disassembles bytecode into readable instructions, one per line, with the source line they came from. Read a small function through it once and several things click: a local variable read is LOAD_FAST, a global or built-in is LOAD_GLOBAL, which explains why local names are faster in hot loops; an attribute read is LOAD_ATTR; a call is CALL with the argument count; and a + b is BINARY_OP. The compiler also does simple optimizations, such as folding 2 * 60 * 60 into 7200 at compile time, which dis shows as a single constant. Python 3.14 shows some instructions in specialized spellings, LOAD_FAST_BORROW for a local that is only read and LOAD_SMALL_INT for a small constant; read them as the base instruction with a hint attached.

Instruction Source it comes from Notes
LOAD_CONST a literal 42, "hi" constants are folded at compile time
LOAD_FAST a local variable array index; the fastest lookup
LOAD_GLOBAL a global or built-in name dictionary lookup; why hot loops localize
LOAD_ATTR obj.attr may invoke a descriptor
BINARY_OP +, -, *, ... dispatches to __add__ etc.
CALL f(...) argument count follows
RETURN_VALUE return

Compilation is cached. When a module is imported, CPython writes its bytecode to __pycache__/module.cpython-314.pyc next to the source, tagged with the interpreter version and the source's modification time and size. The next import compares those and reuses the file if they match, skipping parsing and compiling. That is why the first import of a large package is slower than the second, why __pycache__ belongs in .gitignore, and why deleting it is always safe: it is regenerated. The main script itself is not cached, only imported modules.

Since 3.11 the evaluation loop specializes: after an instruction has run a few times with the same types, it is swapped for a faster type-specific version, and if the types change it falls back. That is most of why recent Python versions are noticeably faster, and it rewards code whose types are stable. 3.13 added an experimental just-in-time compiler that translates hot bytecode into machine code, off by default in most builds and still maturing in 3.14. None of this needs anything from you beyond writing straightforward code; knowing it exists explains benchmarks and why "Python is slow" is an increasingly dated claim.

How CPython runs your code: generic BINARY_OP → specialized BINARY_OP_ADD_INT (fast path)
How CPython runs your code · Figure 2 · Open for full size

Code

Disassembling a small function. Each source line becomes a handful of instructions; a local read is LOAD_FAST (shown as LOAD_FAST_BORROW in 3.14 when the value is only read), a built-in is LOAD_GLOBAL, and the call is CALL.

# disassemble.py
import dis


def total_with_tax(prices, rate):
    subtotal = sum(prices)
    return subtotal * (1 + rate)


names = [ins.opname for ins in dis.get_instructions(total_with_tax)]
print(names)
print("locals via LOAD_FAST:", "LOAD_FAST" in names or any(n.startswith("LOAD_FAST") for n in names))
print("built-in sum via LOAD_GLOBAL:", "LOAD_GLOBAL" in names)
print("one call:", sum(1 for n in names if n == "CALL"))
Output
['RESUME', 'LOAD_GLOBAL', 'LOAD_FAST_BORROW', 'CALL', 'STORE_FAST', 'LOAD_FAST_BORROW', 'LOAD_SMALL_INT', 'LOAD_FAST_BORROW', 'BINARY_OP', 'BINARY_OP', 'RETURN_VALUE']
locals via LOAD_FAST: True
built-in sum via LOAD_GLOBAL: True
one call: 1

Constant folding and the code object. The compiler computes 24 * 60 * 60 before the program runs, and every function carries its bytecode and constants on __code__.

# constants.py
import dis


def seconds_per_day():
    return 24 * 60 * 60


def greet(name):
    return "Hello, " + name


consts = [ins.argval for ins in dis.get_instructions(seconds_per_day) if ins.opname == "LOAD_CONST"]
print("folded at compile time:", consts)
code = greet.__code__
print(code.co_name, code.co_argcount, code.co_varnames, code.co_consts)
print(type(code).__name__, len(code.co_code), "bytes of bytecode")
Output
folded at compile time: [86400]
greet 1 ('name',) ('Hello, ',)
code 20 bytes of bytecode

The bytecode cache in action. Importing a module writes a .pyc tagged with the interpreter version; a second import reuses it, and editing the source invalidates it.

# pycache_demo.py
import importlib
import shutil
import sys
import time
from pathlib import Path

sys.dont_write_bytecode = False          # some runners disable the cache; this demo needs it on
shutil.rmtree("__pycache__", ignore_errors=True)
mod = Path("helper_mod.py")
mod.write_text("VALUE = 1\n", encoding="utf-8")

import helper_mod  # noqa: E402
cached = sorted(Path("__pycache__").glob("helper_mod.*.pyc"))
print("cache written:", [p.name for p in cached])
print("tagged with this interpreter:", f"cpython-{sys.version_info.major}{sys.version_info.minor}" in cached[0].name)

first_mtime = cached[0].stat().st_mtime_ns
mod.write_text("VALUE = 2\n", encoding="utf-8")
new_time = time.time() + 5                 # the cache compares whole seconds of source mtime; move it clearly forward
import os
os.utime(mod, (new_time, new_time))
importlib.invalidate_caches()
importlib.reload(helper_mod)
print("value after edit:", helper_mod.VALUE)
print("cache rewritten after the edit:", cached[0].stat().st_mtime_ns != first_mtime)
Output
cache written: ['helper_mod.cpython-314.pyc']
tagged with this interpreter: True
value after edit: 2
cache rewritten after the edit: True

Specialization is visible. dis with adaptive=True shows the specialized instruction names after a function has been run enough times with stable types.

# specialize.py
import dis


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


before = [i.opname for i in dis.get_instructions(add, adaptive=True) if i.opname.startswith("BINARY_OP")]
for _ in range(200):
    add(1, 2)
after = [i.opname for i in dis.get_instructions(add, adaptive=True) if i.opname.startswith("BINARY_OP")]
print("before warm-up:", before)
print("specialized for ints:", after != before and "INT" in after[0])
Output
before warm-up: ['BINARY_OP']
specialized for ints: True

Try it yourself

Create bytecode.py. Disassemble a function that uses a global constant inside a loop, then a version that copies the constant into a local first, and count the LOAD_GLOBAL and LOAD_FAST instructions in each. Import a small module of your own twice from a fresh interpreter and time both imports with time.perf_counter, then delete __pycache__ and confirm it comes back. Finally, run python -X importtime -c "import json" in your terminal and read which imports cost the most.

Common mistakes

What's next

You know what happens when you press run. Next you move from writing code to keeping it: Git essentials for Python projects.

Course overview