What you'll learn
- Follow a
.pyfile from source text through parsing and compilation to bytecode - Read bytecode with
disand see what a line of Python actually asks the interpreter to do - Understand the
__pycache__folder and when compiled files are reused - Know what the evaluation loop, the specializing interpreter, and the JIT mean for you
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.

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.

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"))
['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: 1Constant 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")
folded at compile time: [86400]
greet 1 ('name',) ('Hello, ',)
code 20 bytes of bytecodeThe 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)
cache written: ['helper_mod.cpython-314.pyc']
tagged with this interpreter: True
value after edit: 2
cache rewritten after the edit: TrueSpecialization 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])
before warm-up: ['BINARY_OP']
specialized for ints: TrueTry 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
- Committing
__pycache__. It is machine- and version-specific and always regenerable; add it to.gitignore. - Optimizing for the bytecode you imagine. Read the real
disoutput; the compiler folds constants and the interpreter specializes, so intuitions from other languages mislead. - Treating the JIT as a switch to flip. It is experimental in 3.14; write clear code and let the interpreter's specialization do its work.
What's next
- CPython parses, compiles to bytecode, then runs an evaluation loop;
disshows the instructions each line becomes. __pycache__caches compiled modules keyed by version and source; it is safe to delete and belongs in.gitignore.- The interpreter specializes hot instructions by observed type since 3.11, and a JIT is arriving; write straightforward code with stable types.
You know what happens when you press run. Next you move from writing code to keeping it: Git essentials for Python projects.