Introduction
Python is often called an "interpreted" language, but that is only half the story. CPython actually compiles your source code to bytecode before executing it, and understanding this compilation pipeline is the first step to writing faster Python. In this lesson, you will learn how Python transforms source code into bytecode instructions and how the interpreter evaluates them.
Key Concepts
- Bytecode: The low-level, platform-independent instruction set that CPython executes. Stored in
.pycfiles under__pycache__/. - Abstract Syntax Tree (AST): The tree representation of your source code that the compiler converts to bytecode.
- Eval Loop: The central
whileloop in CPython (_PyEval_EvalFrameDefault) that fetches, decodes, and executes bytecode instructions one at a time. dismodule: Python's built-in bytecode disassembler that lets you inspect the instructions generated for any function.- BINARY_OP: The unified arithmetic bytecode instruction introduced in Python 3.12, replacing the older per-operation opcodes like
BINARY_ADD.
Real World Context
When you profile a slow function and see that its pure-Python loop is the bottleneck, understanding bytecode tells you exactly what the interpreter is doing on every iteration. This knowledge helps you decide whether to restructure the loop, push work into a C extension, or rely on the JIT compiler introduced in Python 3.13+.
Deep Dive
The Compilation Pipeline
Every time Python runs your code, it goes through these stages:
- Source Code is parsed into an AST
- AST is compiled into Bytecode (
.pycfiles) - Bytecode is executed by the Interpreter (eval loop)
You can inspect the bytecode for any function using the dis module. Here is what the output looks like in Python 3.14:
pythonimport dis def add(a, b): return a + b dis.dis(add)
Python 3.14 output:
1 RESUME 0
2 LOAD_FAST 0 (a)
LOAD_FAST 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE
Notice that the instruction is BINARY_OP with an argument indicating the operator (+), not the older BINARY_ADD opcode that existed before Python 3.12. The RESUME instruction at the top is another modern addition that handles generator/coroutine bookkeeping.
The Eval Loop
CPython's eval loop is essentially a giant switch statement in C. On every iteration it:
- Fetches the next bytecode instruction
- Decodes the opcode and its argument
- Dispatches to the handler for that opcode
- Repeats
This dispatch overhead is one reason pure Python is slower than compiled languages. Each operation (even a + b) requires fetching an instruction, looking up types, calling the appropriate C function, and handling potential errors.
Inspecting Code Objects
Every function has a __code__ object that holds its bytecode and metadata:
pythondef compute(x, y): total = x + y return total * 2 code = compute.__code__ print(f"Stack size: {code.co_stacksize}") print(f"Constants: {code.co_consts}") print(f"Local variables: {code.co_varnames}") print(f"Bytecode bytes: {code.co_code.hex()}")
The Optimization Journey
Python's execution has been getting faster with each release:
| Python Version | Optimization |
|---|---|
| 3.11 | Adaptive Interpreter (Tier 1 specialization) |
| 3.12 | BINARY_OP replaces BINARY_ADD/BINARY_MULTIPLY/etc. |
| 3.13 | Experimental JIT compiler (Tier 2, PEP 744) |
| 3.14 | Improved JIT + Tail-Call Interpreter (3-5% faster) |
The tail-call interpreter in Python 3.14 restructures the eval loop so that each opcode handler directly tail-calls the next one, eliminating the central dispatch overhead and yielding a 3-5% improvement on the pyperformance benchmark suite.
Common Pitfalls
- Assuming
BINARY_ADDstill exists: If you are reading older Python books or tutorials, they will showBINARY_ADDindisoutput. Since Python 3.12, this has been replaced byBINARY_OP. Always check which Python version the material targets. - Ignoring the
RESUMEinstruction: Python 3.14disoutput starts withRESUME. This is not a bug in your code; it is a required instruction for generator and coroutine support and appears in every function. - Over-optimizing bytecode manually: Understanding bytecode is useful for diagnosis, but you should almost never try to hand-craft bytecode. The compiler and adaptive interpreter handle optimization for you.
Best Practices
- Use
dis.dis()to understand performance-critical code: When a function is slow, disassembling it reveals exactly what operations the interpreter runs, helping you identify unnecessary work. - Profile before you optimize: Bytecode knowledge is powerful but should be combined with profiling (
cProfile,timeit) to find actual bottlenecks rather than guessing. - Keep your Python version up to date: Each release brings interpreter improvements. The jump from 3.10 to 3.14 includes specialization, JIT compilation, and the tail-call interpreter.
Summary
- CPython compiles source code to bytecode, then executes it in an eval loop that fetches and dispatches instructions one at a time
- The
dismodule disassembles functions into human-readable bytecode; in Python 3.14 you will seeRESUMEandBINARY_OP(notBINARY_ADD) - Every function has a
__code__object containing its bytecode, constants, and metadata - Python 3.14 introduces a tail-call interpreter that eliminates central dispatch overhead for a 3-5% speed improvement
- Understanding the bytecode pipeline prepares you for the adaptive interpreter and JIT compiler covered in the next lessons
Code Examples
import dis
import sys
def add(a, b):
return a + b
# Disassemble to see Python 3.14 bytecode
# Shows RESUME and BINARY_OP (not the old BINARY_ADD)
dis.dis(add)
# Inspect the underlying code object
code = add.__code__
print(f"\nCode object details:")
print(f" Stack size: {code.co_stacksize}")
print(f" Constants: {code.co_consts}")
print(f" Local vars: {code.co_varnames}")
print(f" Python version: {sys.version}")