Bytecode and the Python Interpreter
15 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Before your program runs, Python turns it into bytecode. Knowing this explains why syntax errors appear first, why imports create .pyc files, and why some tricks are faster than others.
From source code to a code object
Bytecode is the compact list of simple instructions that Python compiles your source into. A virtual machine runs it step by step. It is not machine code and it can change between Python versions.
Python does not run your text directly. It first compiles the source into bytecode, a list of small steps, and keeps it in a code object. Every function has one.
def add(a, b): return a + b
code = add.__code__print(code.co_varnames)print(code.co_argcount)print(type(code.co_code).__name__)Example 1 · Compile, then execute
source = "total = 2 * 3 + 4"code = compile(source, "<demo>", "exec")print(type(code).__name__)print(code.co_names)namespace = {}exec(code, namespace)print(namespace["total"])code
('total',)
10What this code does
compileturns text into a code object without running it.co_nameslists the global names the code uses.execruns the code object, here inside a plain dictionary used as the namespace.
co_varnames lists the local names, co_argcount the parameters, and co_code holds the bytecode as raw bytes. When you import a module, Python saves its bytecode in a __pycache__ folder as a .pyc file, so the next import skips compiling.
Reading bytecode with dis
The dis module lists the instructions of a function. The exact names change between Python versions, so test for the instructions you care about instead of comparing a whole listing.
import dis
def add(a, b): return a + b
names = [i.opname for i in dis.get_instructions(add)]print("BINARY_OP" in names)print(names[-1])Example 2 · A full listing from dis
import dis
def add(a, b): return a + b
dis.dis(add) 3 RESUME 0
4 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP 0 (+)
RETURN_VALUEWhat this code does
- Each row is one instruction; the number on the left is the source line.
- Python 3.13 merges the two loads into one instruction. Other versions show two separate loads, so only the idea matters.
dis.dis(add) prints the full listing with line numbers. Those listings differ between Python 3.12 and 3.13, so treat any printed listing as a sample, never as something to memorise.
The stack machine and constant folding
The Python virtual machine is the loop inside the interpreter that reads one bytecode instruction at a time and carries it out, using a stack for values. CPython is the standard interpreter that contains it.
The virtual machine keeps a stack of values. A load instruction pushes a value, BINARY_OP pops two and pushes the result. The compiler also does easy work early: 24 * 60 becomes 1440 before the program runs.
code = compile("seconds = 24 * 60 * 60", "<demo>", "exec")print(86400 in code.co_consts)print(24 in code.co_consts)Example 3 · Locals and globals use different instructions
import dis
RATE = 18
def with_global(x): return x * RATE
def with_local(x, rate=RATE): return x * rate
def ops(func): return [i.opname for i in dis.get_instructions(func)]
print("LOAD_GLOBAL" in ops(with_global))print("LOAD_GLOBAL" in ops(with_local))True False
What this code does
- A global name is looked up in a dictionary every time.
- A parameter is stored in a numbered slot, so reading it is cheaper.
- The difference is small, so this matters only inside very hot loops.
The multiplication was done once, at compile time, so the loop of the virtual machine never repeats it.
Functions are built when def runs, so defaults are shared
A default argument is a value Python evaluates once, when the def statement runs, and stores on the function. Every call that skips the argument receives that same object.
def is a statement. When it runs, Python builds one function object and stores the default values inside it. A list default is created once, not on every call.
def add_item(item, bucket=[]): bucket.append(item) return bucket
print(add_item("a"))print(add_item("b"))print(add_item.__defaults__)Example 4 · The safe default
def add_item(item, bucket=None): if bucket is None: bucket = [] bucket.append(item) return bucket
print(add_item("a"))print(add_item("b"))print(add_item("c", ["x"]))['a'] ['b'] ['x', 'c']
What this code does
Noneis immutable, so sharing it is harmless.- A fresh list is built inside the call, so calls stay independent.
Both calls changed the same list stored in __defaults__. The safe pattern is bucket=None, then if bucket is None: bucket = [] inside the function.
Next step
Check your understanding
11 quick questions