Bytecode
CPython compiles every function into bytecode — a compact, stack-based instruction set you can inspect directly with the dis module.
Bytecode is the intermediate instruction set that CPython's compiler produces from your source code and that its virtual machine actually executes (see the related Python Execution Internals topic for the full pipeline). It's a stack-based instruction set: most instructions push or pop values on a per-frame value stack rather than referencing named registers. The standard-library dis module disassembles bytecode into human-readable form, making this normally invisible layer directly inspectable.
What Is Bytecode
Bytecode is a low-level, compact set of instructions (opcodes) that CPython's compiler emits from your parsed source and that CPython's interpreter loop executes one instruction at a time. It is not machine code for any physical CPU — it's a portable format specific to CPython's own virtual machine. CPython implementation detail: the exact opcode set, their names, and how many instructions any given piece of source compiles to are not part of the Python language specification; they have changed meaningfully across Python versions (notably 3.11's significant bytecode changes for the specializing adaptive interpreter) and differ entirely in other implementations like PyPy or Jython.
The dis Module
dis.dis(obj) disassembles a function, method, code object, or module into readable bytecode listing. Each line typically shows: the source line number (only where it changes), the instruction's byte offset within the code object, the opcode's name, an optional argument, and a helpful annotation (like the resolved name or constant value).
(Illustrative — exact opcode names/columns vary by Python version.) LOAD_FAST pushes each local variable onto the stack; BINARY_OP pops two values and pushes their sum; RETURN_VALUE pops the result and returns it.
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_VALUEStack-Based Execution
Most bytecode instructions operate on an implicit per-frame value stack rather than named registers: an instruction like LOAD_FAST pushes a local variable's value onto the top of the stack, BINARY_OP pops the top two values, computes a result, and pushes it back, and RETURN_VALUE pops the final value to hand back to the caller. This is why bytecode for even a simple expression like a + b looks like a small sequence of push/pop-style operations rather than one instruction — the stack is the 'scratch space' the VM uses to build up intermediate results.
stack (grows upward, top on the right)
LOAD_FAST a -> [a]
LOAD_FAST b -> [a, b]
BINARY_OP + -> [a+b] (pops a,b; pushes result)
RETURN_VALUE -> [] (pops and returns a+b)Inspecting Different Kinds of Objects
dis.dis accepts functions, bound/unbound methods, generator functions, classes (disassembling each method), code objects (via .__code__), and even a raw source string. For finer-grained programmatic access than the printed listing, dis.get_instructions(obj) yields Instruction named tuples you can iterate and inspect in code.
get_instructions gives structured access to each instruction's opcode name and human-readable argument, useful for programmatic bytecode analysis.
import dis
def square(n):
return n * n
for instr in dis.get_instructions(square):
print(instr.opname, instr.argrepr)Code Objects
Every function, module, and class body compiles to a code object (accessible as func.__code__), which bundles the bytecode itself with metadata: co_consts (literal constants used), co_varnames (local variable names), co_names (global/attribute names referenced), co_argcount, and more. This is the object compile() produces and that a frame is built from when the function is called (see the related Frames & Call Stack topic).
def greet(name, greeting="Hello"):
message = f"{greeting}, {name}!"
return message
code = greet.__code__
print(code.co_varnames) # ('name', 'greeting', 'message')
print(code.co_consts) # (None, 'Hello, ', ...) -- implementation-specific layout
print(code.co_argcount) # 2Comparing Implementations of the Same Logic
Disassembling two ways of writing equivalent logic is a concrete way to see why one might be faster: fewer, simpler bytecode instructions generally (though not always) mean less interpreter overhead. This is illustrative reasoning, not a substitute for actually measuring — see the caveat below.
Comparing the two listings shows the comprehension compiles to a dedicated, tighter instruction sequence (its own small code object built once, then iterated) versus repeated attribute lookups (result.append) inside the explicit loop.
import dis
def with_loop():
result = []
for i in range(5):
result.append(i * i)
return result
def with_comprehension():
return [i * i for i in range(5)]
print("--- loop ---")
dis.dis(with_loop)
print("--- comprehension ---")
dis.dis(with_comprehension)Implementation-Version Caveat
Bytecode is one of the least stable parts of CPython across versions. Python 3.11 introduced the specializing adaptive interpreter (PEP 659), which rewrites certain instructions to faster specialized variants at runtime after observing the actual types involved — meaning the bytecode you see with dis can literally change while a program runs, and opcode names/behavior have shifted release to release (e.g. many LOAD_*/CALL*/jump opcodes were restructured around this era). Never write code, tooling, or tests that depend on exact opcode sequences being stable across Python versions — dis output is a debugging and learning aid, not an API contract.
Practical Usage
Reach for dis when you want to understand *why* two pieces of code perform differently at a mechanical level, when debugging a subtle scoping or closure issue (seeing whether a name resolves via LOAD_FAST, LOAD_GLOBAL, or LOAD_DEREF reveals exactly which namespace it's coming from), or purely as a learning tool for building intuition about the execution model described in the related Python Execution Internals topic. It's rarely something application code inspects at runtime — its main audience is a human reading the disassembly, or specialized tooling (profilers, coverage tools) built around it.
The differing LOAD_* opcode names make the otherwise-invisible distinction between global lookup and closure capture directly visible.
import dis
x = 10
def uses_global():
return x # LOAD_GLOBAL
def uses_closure():
y = 5
def inner():
return y # LOAD_DEREF -- comes from the enclosing scope
return inner
dis.dis(uses_global)
print()
dis.dis(uses_closure())Best Practices
Use dis for understanding and debugging, never as a stable API to depend on programmatically in production code — treat its output as version-specific.
When comparing performance, don't stop at 'fewer bytecode instructions' as a conclusion — actually measure with timeit or a profiler; instruction count is a hint, not a reliable performance predictor on its own (especially with the adaptive interpreter re-specializing instructions at runtime).
Prefer dis.get_instructions() over string-parsing dis.dis()'s printed output if you need to process bytecode programmatically.
Complete Example
Combining dis.dis for a human-readable view with dis.get_instructions for programmatic analysis of the same code object.
import dis
def classify(n: int) -> str:
if n < 0:
return "negative"
elif n == 0:
return "zero"
else:
return "positive"
dis.dis(classify)
# Programmatically count how many comparison instructions this function uses
compare_count = sum(
1 for instr in dis.get_instructions(classify)
if instr.opname in ("COMPARE_OP",)
)
print("comparisons:", compare_count)Mental Model
Think of bytecode as sheet music written for one very specific, very literal-minded instrument (the CPython VM): each note (instruction) tells it to push a value, pop values and combine them, or jump to a different measure. dis is simply a way to read that sheet music instead of just hearing the performance — it doesn't change what plays, it makes the score visible.
Quick Reference
dis.dis(obj) # print human-readable disassembly
dis.get_instructions(obj) # iterate structured Instruction tuples
func.__code__ # the underlying code object
code.co_consts # constants used
code.co_varnames # local variable names
code.co_names # global/attribute names referenced
Common opcodes (names/behavior vary by version):
LOAD_FAST / STORE_FAST local variable
LOAD_GLOBAL global/builtin lookup
LOAD_DEREF closure variable
BINARY_OP arithmetic/binary operator
CALL function call
RETURN_VALUE return from functionKey Takeaways
Bytecode is CPython's compiled, portable instruction set executed by its virtual machine — it is entirely a CPython implementation detail, not Python language specification.
Most instructions operate on an implicit per-frame stack: push values, pop and combine them, push the result.
dis.dis() disassembles functions, methods, and code objects into readable listings; dis.get_instructions() gives structured programmatic access.
A function's __code__ object bundles its bytecode with metadata: constants, variable names, argument count.
Bytecode is unstable across Python versions (especially since 3.11's adaptive interpreter) and entirely different in other implementations — never depend on exact opcodes being stable.
Use disassembly for learning and debugging, and always confirm any performance hypothesis with actual measurement, not instruction counting alone.
Exact bytecode is a CPython implementation detail that changes across versions (notably around 3.11); code or tests that hardcode opcode sequences break on interpreter upgrades.
"The comprehension has fewer bytecode instructions, so it's definitely faster — no need to measure."
"The comprehension has fewer bytecode instructions, which is a hint worth investigating — confirm with timeit before concluding it's faster."
Instruction count correlates with but doesn't guarantee runtime performance, especially with the adaptive interpreter specializing hot instructions at runtime; always measure.
What is bytecode, in relation to source code and the interpreter?
It's the compact, low-level instruction set CPython's compiler produces from parsed source code, which CPython's virtual machine then executes instruction by instruction; it is not native machine code.
Why is bytecode described as 'stack-based'?
Most instructions operate on an implicit per-frame value stack: they push values onto it or pop values off it to compute and push a result, rather than referencing named registers.
What tool disassembles a function into readable bytecode, and what are two ways to invoke it?
The dis module: dis.dis(obj) prints a human-readable listing, and dis.get_instructions(obj) yields structured Instruction objects for programmatic use.
Why should you never write code that depends on exact opcode sequences being stable across Python versions?
Bytecode is a CPython implementation detail that has changed substantially across versions — notably with the 3.11 specializing adaptive interpreter — and differs entirely between CPython, PyPy, and other implementations.
What is a code object, and where do you access one from a function?
A code object bundles a function's/module's/class's compiled bytecode with metadata like constant values, local variable names, and argument count; access it via func.__code__.
What does seeing LOAD_DEREF instead of LOAD_GLOBAL in a disassembly tell you about a name?
It tells you the name is being resolved as a closure variable captured from an enclosing function's scope, rather than looked up in the module's global namespace.
Why is comparing instruction counts between two implementations not a substitute for actual performance measurement?
Instruction count is only a rough proxy for cost — actual runtime depends on what each instruction does, how the adaptive interpreter specializes hot paths, and other factors that only real measurement (e.g. timeit) captures.
Write code that disassembles a function is_even(n) that returns n % 2 == 0, and prints just the opnames of its instructions using dis.get_instructions.
import dis def is_even(n: int) -> bool: return n % 2 == 0 for instr in dis.get_instructions(is_even): print(instr.opname)
Prints the sequence of opcode names (e.g. RESUME, LOAD_FAST, LOAD_CONST, BINARY_OP, LOAD_CONST, COMPARE_OP, RETURN_VALUE — exact names/order depend on the Python version) that compute n % 2 == 0 and return it.
What does this print?
('x', 'y') — co_varnames lists the code object's local variable names in the order they're assigned/used, not including any global names referenced.
Write a function opcode_histogram(func) that returns a dict counting how many times each opcode name appears in func's bytecode.
import dis from collections import Counter def opcode_histogram(func) -> dict[str, int]: return dict(Counter(instr.opname for instr in dis.get_instructions(func))) def example(a, b): return a + b + a print(opcode_histogram(example))
Returns a dict mapping each distinct opcode name to how many times it occurs — for example LOAD_FAST would count 3 (once for each of a, b, a) while BINARY_OP would count 2, illustrating how repeated variable use shows up directly in the instruction counts.
def f():
x = 1
y = 2
return x + y
print(f.__code__.co_varnames)