Home Tutorials Internals

Internals

How Python Executes Your Code: Bytecode and the dis Module

Pyford Notes July 6, 2026 6 min read
Key points
  • Python source is compiled to bytecode before it runs; the interpreter never reads .py text directly during execution.
  • Compiled bytecode for imported modules is cached as .pyc files under __pycache__ so it doesn't need recompiling on every run.
  • The dis module disassembles a function into its bytecode instructions, showing exactly what operations the interpreter will perform.
  • CPython's interpreter loop is a stack machine: most instructions push values onto or pop values off an operand stack.

From source to bytecode: what compile() does

Running a Python script doesn't interpret the text of your source file line by line the way a shell script might. The source is first parsed into an abstract syntax tree, then compiled down to bytecode — a sequence of low-level instructions for a virtual machine defined by CPython itself. This happens automatically whenever you run a script or import a module, but you can trigger it explicitly with the built-in compile() function:

src = "x = 1 + 2\nprint(x)"
code_obj = compile(src, "", "exec")
print(type(code_obj))       # 
exec(code_obj)               # 3

The result is a code object, which holds the compiled instructions along with metadata like variable names and constants. It's this object the interpreter actually runs, not your original text.

The .pyc cache and __pycache__

Recompiling every imported module on every run would be wasteful, so Python caches the compiled bytecode. The first time a module is imported, Python writes a .pyc file into a __pycache__ directory next to it, named after the module and the interpreter version, such as utils.cpython-312.pyc. On later imports, Python checks the source file's modification time and size against what's recorded in the cache; if nothing has changed, it loads the cached bytecode directly and skips compilation entirely. This is why a large project's second run of the same script often starts noticeably faster than the first. The cache applies to imported modules, not to the script you run directly with python script.py — that entry point is always recompiled.

Reading dis.dis() output

The dis module turns a code object back into a readable listing of its bytecode instructions:

import dis

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

dis.dis(add)
  2           0 RESUME                   0

  3           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

Each line pairs a source line number with one or more instructions. LOAD_FAST pushes a local variable's value onto the stack, BINARY_OP pops the top two values and pushes the result of applying an operator to them, and RETURN_VALUE pops the final result and hands it back to the caller. Reading this doesn't require memorising every opcode — the useful skill is recognising the shape: how many instructions a line expands into, and whether an operation you assumed was cheap is actually doing more work than it looks.

Why the interpreter is a stack machine

CPython's interpreter loop is built around an operand stack rather than registers. Every instruction either pushes a value onto that stack, pops one or more values off it, or does both — LOAD_FAST pushes, BINARY_OP pops two and pushes one, RETURN_VALUE pops one and exits the function. This design makes the compiler's job simpler, since expressions translate naturally into a sequence of pushes and pops without needing to allocate registers, at some cost to raw execution speed compared to a register-based design. It's also why deeply nested expressions produce longer instruction sequences than they might appear to from the source: each sub-expression's result has to be pushed and later consumed in order.

Practical reasons to look at bytecode

Most Python code is written and debugged without ever looking at bytecode, and that's normal. It becomes useful in a few specific situations: confirming that a supposedly equivalent rewrite of a hot function actually compiles to fewer instructions before assuming it's faster; understanding why a is b sometimes surprises people with small integers or short strings, which CPython interns and reuses; or diagnosing why a decorator or metaclass is producing behaviour that doesn't match the source. It is not a substitute for a real profiler when the question is "where does the time go" — for that, cProfile measures actual elapsed time per function, while dis only shows instruction counts, which don't map cleanly onto wall-clock cost. The full opcode reference is in the official dis module documentation.

dis can also settle arguments that would otherwise stay theoretical. A common one is whether string concatenation with += inside a loop is genuinely worse than building a list and joining it at the end; disassembling both versions shows the loop-and-join approach avoiding the repeated BINARY_OP and store cycle that the naive concatenation produces on every iteration, which lines up with the commonly measured timing difference rather than just asserting it. It's also worth knowing that dis.dis() works on more than plain functions — it accepts classes, disassembling every method in turn, and modules, which is occasionally the fastest way to confirm that a decorator or a metaclass is actually wrapping the function you think it is, rather than silently swapping in something else.