Topic 13.3
Bytecode and the dis Module
In one line
CPython compiles your source into bytecode, a list of small instructions for a stack-based virtual machine, and stores it in a code object. The dis module prints that bytecode, which shows what a line really costs and how the specialising interpreter speeds up hot code.
Think of it like this
A cooking show. The chef writes the recipe in everyday words (your source code). A producer turns it into a card with numbered, tiny steps: 'take egg, take bowl, crack egg into bowl'. A kitchen robot then follows the card one step at a time. The card is bytecode, and the robot is the Python virtual machine (the eval loop written in C). Topic 0.8 showed this pipeline briefly; here we read the card.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Bytecode
- The low-level instructions CPython compiles your code into. It is not machine code.
- Code object
- The compiled form of a function, module or class body: instructions plus constants and names.
- Virtual machine (VM)
- The C program (the eval loop) that runs bytecode one instruction at a time.
- Stack machine
- A machine that works by pushing values on a stack and popping them to compute.
- Opcode
- The name of one bytecode instruction, such as
LOAD_FAST. - Constant folding
- Doing arithmetic on constants at compile time, so
6 * 7becomes42. - Specialising (adaptive) interpreter
- A VM that rewrites hot instructions into faster versions for the types it has seen. Added in 3.11.
- JIT
- Just-in-time compiler: turns hot code into machine code while the program runs. Experimental in 3.13.
Step by step
01Read one function's bytecode
We disassemble a function of two lines. The first column is the source line, then the opcode, then its argument, then a readable hint in brackets.
Follow the stack. LOAD_FAST_LOAD_FAST pushes width and height. BINARY_OP 5 (*) pops them and pushes the product. STORE_FAST result pops it into the local result. On the next line, result and the constant 1 are pushed, BINARY_OP 0 (+) adds them, and RETURN_VALUE returns the top of the stack.
import dis
def area(width, height):
result = width * height
return result + 1
dis.dis(area)02The stack, step by step
The stack grows and shrinks as the instructions run. The deepest it ever gets is stored in the code object (co_stacksize), so the VM can reserve exactly that much.
03Open the code object
Every function has a __code__ attribute. Looking inside shows the data the instructions refer to: the argument names, all the local names (arguments first), and the constants. The first slot of co_consts holds the function's docstring, or None when there is none (in this function it is None).
def scale(values, factor):
total = 0
for v in values:
total += v * factor
return total
code = scale.__code__
print(code.co_varnames) # ('values', 'factor', 'total', 'v')
print(code.co_consts) # (None, 0)04compile, exec and constant folding
compile(source, filename, mode) is the compiler as a function. It returns a code object. exec runs it. Looking at co_consts shows that 6 * 7 has become the constant 42: the multiplication ran at compile time.
This is what import does behind the scenes, plus saving the result as a .pyc file in __pycache__ so next time it can skip the compile step.
05Why locals are fast and globals are slower
A local variable has a fixed slot number, so LOAD_FAST is an array index. A global name needs a dictionary lookup by name at run time, and an attribute (obj.attr) needs the lookup chain from Topic 13.4. This is the root of the old advice to bind append = result.append before a hot loop. Today the specialising interpreter caches many of those lookups, so measure before you rewrite code that way (Topic 13.7).
06Watch the interpreter specialise
After a function has run a thousand times with two ints, dis.dis(add, adaptive=True) shows BINARY_OP_ADD_INT instead of the generic BINARY_OP. The specialised instruction checks both operands are ints and adds them without the general dispatch. If you later call add with strings, the check on that instruction fails and the VM runs the generic path for that call. Only after the instruction keeps missing does it drop the specialised form and, if the new types are stable, specialise again, this time to something like BINARY_OP_ADD_UNICODE. One odd call does not undo the specialisation.
The names come from CPython 3.13. Other versions use different names and numbers of instructions.
07Where the JIT and other speed-ups fit
A JIT trades start-up time and memory for faster hot loops. PyPy has had a mature tracing JIT for years. CPython 3.13 added an experimental one that is off in normal builds, and it remains a work in progress; the supported speed-ups you get by default are the specialising interpreter, cheaper frames and faster calls.
Try it yourself
- 1
Add a line and read the difference
In the first example add
total = result * 2before thereturn. Run it and find the newLOAD_FAST,LOAD_CONST,BINARY_OPandSTORE_FASTinstructions. Which line number do they belong to? - 2
Break the specialisation
In the third example, call
add("a", "b")once after the warm-up loop and disassemble again: the name staysBINARY_OP_ADD_INT, because one miss is not enough. Now call it a thousand times with strings and disassemble again. The instruction becomesBINARY_OP_ADD_UNICODE. Try floats or lists and watch the name change. - 3
Spot the folded constant
Change the source in the second example to
"answer = 60 * 60 * 24". Look atco_constsand find the single constant 86400.
Code & diagrams
Opcode names and numbers are version-specific. Run it on another version and compare.
Expected output
4 RESUME 0
5 LOAD_FAST_LOAD_FAST 1 (width, height)
BINARY_OP 5 (*)
STORE_FAST 2 (result)
6 LOAD_FAST 2 (result)
LOAD_CONST 1 (1)
BINARY_OP 0 (+)
RETURN_VALUEExpected output
name: scale
arguments: ('values', 'factor')
all locals: ('values', 'factor', 'total', 'v')
constants: (None, 0)
global names: ()
stack needed: True
bytecode is bytes: True
constants: (42, None)
answer: 42
has a loop opcode: True
first opcode: RESUMECompare with the generic `BINARY_OP` in the first example. The adaptive form exists only after the function has run enough times.
Expected output
4 RESUME_CHECK 0
5 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP_ADD_INT 0 (+)
RETURN_VALUEBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Disassemble something that is not code
Pass a number to dis.dis.
import dis
dis.dis(42)Myth vs fact
Myth
Python is interpreted, so it has no compile step.
Fact
Python always compiles source to bytecode first. The VM then interprets that bytecode. The compile step is fast and its result is cached in __pycache__.
Myth
Bytecode is the same on every Python version.
Fact
It changes in almost every release, which is why .pyc files are tied to a version and why dis output differs between versions.
Myth
The JIT in 3.13 makes Python as fast as C.
Fact
It is experimental, off in normal builds, and gives small gains today. The specialising interpreter of 3.11 gave larger improvements.
Myth
Micro-optimising bytecode is a good use of time.
Fact
The big wins are algorithms and data structures. Reading bytecode is for understanding, and for the rare hot spot after profiling.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Specialisation relies on inline caches: small slots stored inside the bytecode where the VM remembers, for example, the type version and the attribute slot it found last time. A stable type shape therefore makes code faster, and changing a class at run time (adding attributes after creation, or patching methods) invalidates caches.
- ▸
sys.settrace,sys.monitoring(3.12), debuggers, coverage tools and profilers all work at the level of bytecode events and line numbers from theco_lines()table. Understanding code objects explains how tools likecoverage.pyknow which lines ran. - ▸
A
.pycfile stores a marshalled code object with a header including a magic number tied to the Python version and a source timestamp or hash. Python ignores a.pycwhose magic number does not match, which is why the cache folder is safe to delete. - ▸
Free-threaded builds (Topic 12.2) and the JIT both rely on changes in the interpreter and in how objects are counted, which is one reason they are separate build options. Free-threading was experimental in 3.13 and officially supported (but not the default) from 3.14; the JIT was still experimental in 3.14.
- ▸
dis.dis(f, show_caches=True)prints the hiddenCACHEentries that sit after instructions such asLOAD_ATTRandBINARY_OP; those are the inline caches.dis.Bytecode(f)anddis.get_instructions(f)give the same information as objects, withopname,argval,offsetandpositions, which is what tools build on. Exception handling is not done by bytecode: since 3.11trycosts nothing when no error happens, because the handlers live in a separate exception table (co_exceptiontable) that is read only when an exception is raised. - ▸
Compile-time work you can observe with
dis: constant folding, dropping code afterreturn, and forif False:blocks removing the dead branch. The optimiser is conservative about anything that could change behaviour, for example it does not fold1 / 0(it raises at run time, not at compile time) or very large results. Code underif __debug__:andassertdisappears withpython -O, which is one more reason never to useassertfor validation of input.
Remember this
- 1
When a module is imported or a script runs, the compiler parses the source, builds a syntax tree (the program's structure as nested nodes), and emits a code object for the module and one for every function, class body and comprehension. A code object holds the instruction bytes (
co_code), the constants used (co_consts), the names of global and attribute lookups (co_names), the local variable names (co_varnames), and the stack size the function needs. A function object is a code object plus its defaults and closure. - 2
The virtual machine is a stack machine. Instead of registers (named slots inside a CPU), it has a small stack: a pile where you only add or remove at the top, like a stack of plates.
LOAD_FAST widthpushes a local variable.BINARY_OPpops two values, multiplies them and pushes the result.STORE_FAST resultpops the top and saves it into a local.RETURN_VALUEpops the top and returns it. Every expression becomes a short sequence of pushes and pops. - 3
dis.dis(function)prints the instructions with line numbers. The exact opcode names change between versions: Python 3.13 showsLOAD_FAST_LOAD_FASTwhere older versions show twoLOAD_FASTlines. What stays the same is the idea. Never write code that parsesdisoutput; it is a learning and debugging aid. - 4
Locals are stored in a fixed array, which is why
LOAD_FAST(a local) is fast andLOAD_GLOBALorLOAD_ATTR(a dict lookup, Topic 13.4) is slower. That is the reason behind the old tip of copying a global function into a local name inside a hot loop. Constants such as6 * 7are folded at compile time into42, so no multiplication runs at all. - 5
Since Python 3.11 the interpreter is adaptive and specialising (PEP 659; a PEP is a Python Enhancement Proposal, the numbered design document for a change). After a piece of bytecode runs a few times, the VM rewrites it in place into a faster form for the types it saw, for example
BINARY_OP_ADD_INTfor adding two ints. If the types change, it falls back.dis.dis(f, adaptive=True)shows the specialised version after a warm-up. This is the main reason 3.11 was about 25 percent faster than 3.10 on average (the official figure was 10 to 60 percent, depending on the program), together with cheaper function calls. - 6
Python 3.13 added an experimental JIT (just-in-time compiler, PEP 744). It is not in the usual builds: it is compiled in with a build option and, even then, switched on with an environment variable (
PYTHON_JIT=1). It uses a technique called copy-and-patch: hot sequences of bytecode are turned into small pre-built machine-code templates. It is a first step and the gains so far are modest and depend on the program; it was still experimental in 3.14, and you should not count on it. For real speed today, use the right algorithm, NumPy, or another tool (Topic 13.8).
Explain it without notes
What is bytecode, and who runs it?
What does a stack-based virtual machine do for the expression a * b + 1?
What is the specialising adaptive interpreter, and what changed in Python 3.11?
Why can x = 6 * 7 run no multiplication at run time?
Why does a try block cost almost nothing when no exception is raised, and what does that tell you about EAFP style?
Practice
Print the opcode names of lambda x: x + 1 with dis.get_instructions.
Print the co_varnames and co_consts of a function that has a parameter n and a local string text.
Compile the expression "2 * 21" in eval mode, print its constants, and evaluate it.
Trade-offs
- ↔
Bytecode gives portability but not speed. The same
.pycruns on any machine with the same Python version, but each instruction is still interpreted. Compiling to machine code (a JIT, Cython, Numba) is faster and less portable. - ↔
Specialisation helps stable code. Hot functions that always see the same types gain; code whose types vary a lot (one function called with ints, strings and lists) benefits less.
Done when you can
Done when you can read a short
dislisting and follow the stack.Done when you can name the parts of a code object and what each holds.
Done when you can explain constant folding and the speed difference between locals and globals.
Done when you can explain what the specialising interpreter does and how to see it.
Done when you can say what the 3.13 JIT is and why you should not rely on it yet.