dis — inspecting Python bytecode
CPython compiles source to bytecode (a sequence of opcodes for a stack-based VM). The dis module shows you what opcodes a function actually runs. This is mainly used for: optimization, understanding subtle behavior, and answering “what’s faster” questions definitively.
Basic use
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: bytecode offset, opcode, operand, mnemonic comment.
Why care?
Settling micro-optimization arguments
“Is a < b < c faster than a < b and b < c?”
def chained(a, b, c):
return a < b < c
def expanded(a, b, c):
return a < b and b < c
dis reveals chained uses one fewer load (the middle operand is duplicated on the stack with COPY/SWAP, not reloaded). Slightly faster, but in practice negligible.
Understanding closures
Closure cells show up as LOAD_DEREF / STORE_DEREF rather than LOAD_FAST:
def outer():
x = 1
def inner():
return x
return inner
dis.dis(outer)
# ...
# MAKE_CELL 0 (x)
# LOAD_CONST 1 (1)
# STORE_DEREF 0 (x)
The MAKE_CELL is what creates the closure storage.
Spotting expensive patterns
Calling .append in a tight loop generates LOAD_METHOD per iteration. Hoisting it (append = lst.append; append(x)) avoids the per-iteration method lookup. dis proves it.
Key opcodes worth knowing
| Opcode | What it does |
|---|---|
LOAD_FAST |
Read a local variable (fastest) |
LOAD_GLOBAL |
Read a global (slower; dict lookup) |
LOAD_DEREF |
Read a closure cell |
LOAD_CONST |
Push a literal constant |
LOAD_ATTR |
Get an attribute (e.g. obj.x) |
STORE_FAST |
Set local variable |
BINARY_OP |
+, -, *, etc. (Python 3.11+ unified these) |
CALL |
Function call (replaced CALL_FUNCTION in 3.11+) |
RETURN_VALUE |
Pop top of stack and return |
JUMP_FORWARD, POP_JUMP_IF_FALSE |
Branching |
RESUME |
Marker for jit / debug — newer versions |
Performance implication: globals are slow(er) than locals
import math
def slow():
return math.sin(0.5) # LOAD_GLOBAL math, LOAD_ATTR sin, CALL
def fast(sin=math.sin):
return sin(0.5) # LOAD_FAST sin, CALL
The “default arg as cache” trick is sometimes used in hot loops. It’s micro — usually only matters in inner loops with millions of iterations.
Modern bytecode changes (3.11+)
Python 3.11 introduced “specialized” opcodes via PEP 659 (adaptive interpreter). Common opcodes get specialized to common types — BINARY_OP becomes BINARY_OP_ADD_INT after a few iterations on integers. You’ll see these in dis.dis(func, adaptive=True).
Practical use: dis.show_code
dis.show_code(add)
# Name: add
# Filename: <stdin>
# Argument count: 2
# Variable names: a, b
# Stack size: 2
# ...
Shows constants, names, free vars — useful for understanding closures and frame size.
Interview angle
Less common in interviews, but valuable when discussing performance: “How would you compare the speed of two equivalent Python expressions?” (Use timeit for empirical, dis for theoretical — count opcodes.) “Why is accessing a local faster than a global?” (LOAD_FAST is array index, LOAD_GLOBAL is dict lookup with hash.)