The dis Module and Bytecode Disassembly
The standard-library
dismodule is the window into the compiler’s output: it disassembles the code object of any function, method, class, module, or source string into human-readable bytecode instructions, showing the exact opcodes, arguments, jump targets, source-line mapping, and — on a running interpreter — the specialized opcodes and inline-cache slots that the adaptive interpreter writes in place. It is the canonical answer to “what does this code actually compile to?” The high-level API is three things:dis.dis()to print a disassembly,dis.get_instructions()to iterate over structuredInstructionrecords, and thedis.Bytecodeclass that wraps a code object as an analyzable, iterable object. All output is verified here against CPython 3.14.5 (dis docs, 3.14).
Bytecode is an implementation detail, not a contract
The CPython docs state plainly that bytecode is “an implementation detail of the CPython interpreter” — opcodes are added, removed, and renumbered between feature releases, and within a release the adaptive interpreter mutates the running code.
disis a diagnostic and teaching tool, not a stable API to build on. Every opcode and offset shown below is specific to 3.14.5.
Mental Model: A Reader for the Code Object
A code object stores its instructions as a flat bytes blob in co_code, alongside parallel arrays — co_consts (constants), co_names (global/attribute names), co_varnames (locals) — and compressed side tables for source positions and exception handlers. None of that is readable by eye. dis is the decoder: it walks co_code two bytes at a time (each instruction is a 1-byte opcode plus a 1-byte argument, with EXTENDED_ARG prefixes for larger args and CACHE slots interleaved), resolves each argument against the parallel arrays to a meaningful value, recovers the source line and column from the location table, and prints or yields the result.
graph LR OBJ["function / code object /<br/>source string / traceback"] --> GI["get_instructions()<br/>decode co_code 2 bytes at a time"] GI --> INS["Instruction records<br/>(opname, arg, argval,<br/>offset, positions, ...)"] INS --> FMT["Formatter<br/>lay out columns"] FMT --> OUT["dis.dis() text output"] INS --> BC["dis.Bytecode<br/>(iterable wrapper)"] OBJ --> ADAPT{"adaptive=True?"} ADAPT -->|yes| LIVE["read the LIVE co_code:<br/>specialized opcodes + cache state"] ADAPT -->|no| BASE["the generic, as-compiled opcodes"]
Diagram: dis’s two data paths. get_instructions() is the structured core that everything else is built on — dis.dis() just formats its output into columns, and dis.Bytecode wraps it as an object. The branch at the bottom is the crucial 3.11+ subtlety: with adaptive=False (the default) you see the bytecode as the compiler emitted it; with adaptive=True you see the live, possibly type-specialized bytecode currently sitting in the running code object’s memory.
The Three Entry Points
dis.dis() — print a disassembly
dis.dis(x=None, *, file=None, depth=None, show_caches=False,
adaptive=False, show_offsets=False, show_positions=False)Disassembles x — which may be a module, class, method, function, generator, async generator, coroutine, code object, string of source, or raw bytecode — and writes to file (default sys.stdout). For a class it disassembles every method; for a module, every top-level function/class it can find. If x is None it disassembles the last traceback (handy in an interactive crash). depth limits recursion into nested code objects (0 = don’t recurse). The keyword-only flags are covered below. Signature per the 3.14 docs.
dis.get_instructions() — iterate structured records
dis.get_instructions(x, *, first_line=None, show_caches=False, adaptive=False)Returns an iterator of Instruction named tuples — the programmatic core. This is what you use to analyze bytecode (count opcodes, find all LOAD_GLOBAL names, build a CFG) rather than read it. Note (per the docs, changed in 3.13) that show_caches is deprecated here: the iterator now always populates each instruction’s cache_info field instead of emitting separate CACHE pseudo-instructions, so you get the cache data structurally without asking.
dis.Bytecode — an analyzable wrapper
class dis.Bytecode(x, *, first_line=None, current_offset=None,
show_caches=False, adaptive=False,
show_offsets=False, show_positions=False)Wraps a code object as an iterable that yields Instructions, plus convenience methods: .dis() returns the formatted text (same as dis.dis() would print), .info() returns the detailed code-object metadata (same as dis.code_info()), and the classmethod Bytecode.from_traceback(tb) builds one from a traceback with current_offset set to the instruction that raised — so a disassembly can mark the exact failing opcode with -->. Properties .codeobj and .first_line expose the underlying code object and its first line.
Lower-level helpers round out the module: dis.code_info(x)/dis.show_code(x) dump the co_* metadata, dis.findlinestarts(code) yields (offset, lineno) pairs from co_lines() (PEP 626; line numbers can be None for synthetic bytecode, changed in 3.13), and dis.distb/dis.disassemble/dis.disco are traceback/code-object variants.
Reading the Columns: a Worked Example
Disassembling a function. The source below is lines 3–4 of the file that produced this output (a two-line import dis preamble precedes it), which is why the line-number column reads 3 and 4:
# (line 1) import dis
# (line 2)
def myfunc(alist): # line 3
return len(alist) # line 4
dis.dis(myfunc)Real 3.14.5 output:
3 RESUME 0
4 LOAD_GLOBAL 1 (len + NULL)
LOAD_FAST_BORROW 0 (alist)
CALL 1
RETURN_VALUE
Reading it column by column (the layout is produced by dis.py’s Formatter, verified at v3.14.5):
- Source line (
3,4): the source line number, printed only on the first instruction of each line. The blank line betweenRESUMEandLOAD_GLOBALisdis’s way of grouping instructions by source line. - Opcode name (
RESUME,LOAD_GLOBAL…): theopname, left-justified to 20 characters. - Argument (
0,1,0,1): the raw integeroparg, right-justified to 5 characters. ForRETURN_VALUE(no argument) the column is blank. argvalinterpretation ((len + NULL),(alist)): the human-readable resolution of the argument.LOAD_GLOBAL 1resolves to the namelen(index 1 inco_names); the+ NULLannotation means the opcode also pushes aNULLfor the upcoming call convention.LOAD_FAST_BORROW 0resolves to the localalist(index 0 inco_varnames).
Two columns appear only when you ask: the --> current-instruction marker (when current_offset is set, e.g. from a traceback) and the >> jump-target marker. In 3.13+ jump targets are shown as labels (L1:, L2:) by default instead of raw offsets; pass show_offsets=True to restore byte offsets:
dis.dis(myfunc, show_offsets=True)
# 3 0 RESUME 0
# 4 2 LOAD_GLOBAL 1 (len + NULL)
# 12 LOAD_FAST_BORROW 0 (alist)
# ...The jump from LOAD_GLOBAL at offset 2 to LOAD_FAST_BORROW at offset 12 is 10 bytes, not 2 — because LOAD_GLOBAL carries four inline CACHE slots (8 bytes) for its inline cache, invisible unless you ask for them.
The Instruction Named Tuple: Stored Fields vs. Computed Properties
get_instructions() yields Instruction objects. There is a subtlety the docs blur but the source makes precise. In Lib/dis.py (v3.14.5), Instruction subclasses a collections.namedtuple called _Instruction whose twelve stored fields are exactly:
('opname', 'opcode', 'arg', 'argval', 'argrepr', 'offset',
'start_offset', 'starts_line', 'line_number', 'label',
'positions', 'cache_info')Everything else the docs list as a field is actually a @property computed from these — not a slot in the tuple. Per Lib/dis.py the computed properties are:
oparg— alias forarg.baseopname/baseopcode— the generic op name/number when this instruction is a specialized variant (e.g.LOAD_ATTR_INSTANCE_VALUE→ baseLOAD_ATTR); otherwise equal toopname/opcode. (Added 3.13.)cache_offset(self.offset + 2) andend_offset— the byte range of the trailing cache entries.jump_target— the destination bytecode index if this is a jump, elseNone.is_jump_target—Trueiffself.label is not None(i.e. some other instruction jumps here).
This matters in practice: Instruction._fields returns the twelve stored names, not the full attribute set you can read, so reflective code that walks _fields will miss baseopname, jump_target, etc. Confirming with the live interpreter:
>>> import dis
>>> dis.Instruction._fields
('opname', 'opcode', 'arg', 'argval', 'argrepr', 'offset',
'start_offset', 'starts_line', 'line_number', 'label',
'positions', 'cache_info')A single record from get_instructions(myfunc) (real 3.14.5):
Instruction(opname='LOAD_GLOBAL', opcode=92, arg=1, argval='len',
argrepr='len + NULL', offset=2, start_offset=2,
starts_line=True, line_number=4, label=None,
positions=Positions(lineno=4, end_lineno=4,
col_offset=11, end_col_offset=14),
cache_info=[('counter', 1, b'\x00\x00'),
('index', 1, b'\x00\x00'),
('module_keys_version', 1, b'\x00\x00'),
('builtin_keys_version', 1, b'\x00\x00')])Note cache_info: four named cache entries (counter, index, module_keys_version, builtin_keys_version) — exactly the inline-cache layout LOAD_GLOBAL reserves for specialization. start_offset equals offset here, but differs when an instruction is preceded by EXTENDED_ARG: offset points at the real opcode while start_offset points at the first EXTENDED_ARG (added 3.13, for jump-arithmetic correctness).
Adaptive Disassembly: Seeing the Specializing Interpreter
By default dis shows the bytecode as compiled — generic opcodes. But since 3.11 (PEP 659, PEP 659) the running interpreter rewrites hot instructions in place into type-specialized variants and fills their inline caches. Two flags expose this live state (see Inline Caches and Quickening and The Specializing Adaptive Interpreter).
show_caches=True reveals the otherwise-hidden CACHE entries — the reserved bytes that hold per-call-site specialization state:
dis.dis(myfunc, show_caches=True)
# 4 LOAD_GLOBAL 1 (len + NULL)
# CACHE 0 (counter: 0)
# CACHE 0 (index: 0)
# CACHE 0 (module_keys_version: 0)
# CACHE 0 (builtin_keys_version: 0)
# LOAD_FAST_BORROW 0 (alist)
# CALL 1
# CACHE 0 (counter: 0)
# CACHE 0 (func_version: 0)
# CACHE 0
# RETURN_VALUEEach CACHE line names the cache field and shows its current value (all 0 because this code is cold). The four cache slots after LOAD_GLOBAL and the three after CALL are why those instructions occupy 10 and 8 bytes respectively in the offset view.
adaptive=True shows the specialized opcodes after the interpreter has warmed up. Take a hot loop and run it ~1000 times first:
def addup(xs):
t = 0
for x in xs:
t += x
return t
for _ in range(1000):
addup([1, 2, 3, 4, 5])
dis.dis(addup, adaptive=True)Real 3.14.5 output (relevant lines):
RESUME_CHECK 0
LOAD_SMALL_INT 0
...
FOR_ITER_LIST 11 (to L2)
...
LOAD_FAST_BORROW_LOAD_FAST_BORROW 18 (t, x)
BINARY_OP_ADD_INT 13 (+=)
...
JUMP_BACKWARD_NO_JIT 13 (to L1)
Every opcode here is a specialization the interpreter installed by observing runtime types:
RESUME→RESUME_CHECK(the no-eval-breaker fast path).FOR_ITER→FOR_ITER_LIST(specialized for iterating alist).BINARY_OP→BINARY_OP_ADD_INT(specialized forint + int, skipping the generic dispatch through__add__).LOAD_FAST_BORROW + LOAD_FAST_BORROWfused intoLOAD_FAST_BORROW_LOAD_FAST_BORROW(a super-instruction loading two locals at once).LOAD_SMALL_INT(new in 3.14) replacingLOAD_CONSTfor the small integer0.
Without adaptive=True you would see the generic FOR_ITER, BINARY_OP, and LOAD_CONST — the as-compiled form. This flag is the single best way to see the specializing interpreter at work. (The command-line equivalents arrived in 3.14: python -m dis --specialized (-S) shows specialized bytecode and --show-positions (-P) shows full source spans, per What’s New in 3.14.)
Source Positions: co_positions() and PEP 657
Since 3.11 (PEP 657, PEP 657) every instruction carries not just a line number but a full (start_line, end_line, start_col, end_col) span — the data that lets a traceback underline the exact sub-expression that failed (e.g. which [...] in a[b][c][d]). dis surfaces this two ways: the positions field of each Instruction (a dis.Positions named tuple with fields lineno, end_lineno, col_offset, end_col_offset), and the show_positions=True flag (new in 3.14) that prints the full span instead of just the line. The raw data lives on the code object as code.co_positions(), a generator yielding one four-tuple per instruction. Reading it directly:
def squares(n):
return [i*i for i in range(n)]
for ins in dis.get_instructions(squares):
p = ins.positions
print(f"{ins.offset:>3} {ins.opname:<22} line {p.lineno} cols {p.col_offset}-{p.end_col_offset}")
# 2 LOAD_GLOBAL line 2 cols 25-30 <- 'range'
# 12 LOAD_FAST_BORROW line 2 cols 31-32 <- 'n'
# 40 BINARY_OP line 2 cols 12-15 <- 'i*i'
# ...The column spans pinpoint each sub-expression: range at columns 25–30, n at 31–32, the multiplication i*i at 12–15. This is exactly the data a 3.11+ traceback uses to draw ^^^^ carets under the failing expression. Note that some compiler-synthesized instructions have None positions (no source maps to them) — dis prints those as None.
A Comprehension Disassembly: PEP 709 Inlining in Action
Comprehensions are a good stress test because their compilation changed structurally in 3.12 (PEP 709, PEP 709): list/dict/set comprehensions are now inlined into the containing function rather than compiled as a separate nested code object with its own frame. Disassembling the squares function above shows the inlined body directly — there is no nested <listcomp> code object to recurse into:
LOAD_GLOBAL 1 (range + NULL)
LOAD_FAST_BORROW 0 (n)
CALL 1
GET_ITER
LOAD_FAST_AND_CLEAR 1 (i) <- save outer 'i', clear the slot
SWAP 2
BUILD_LIST 0
SWAP 2
FOR_ITER 11 (to L3)
STORE_FAST_LOAD_FAST 17 (i, i)
LOAD_FAST_BORROW 1 (i)
BINARY_OP 5 (*)
LIST_APPEND 2
JUMP_BACKWARD 13 (to L2)
END_FOR
POP_ITER
SWAP 2
STORE_FAST 1 (i) <- restore outer 'i'
RETURN_VALUE
...
ExceptionTable:
L1 to L4 -> L5 [2]
The PEP 709 machinery is visible: LOAD_FAST_AND_CLEAR saves any outer value of the loop variable i onto the stack and clears the slot (so the inlined comprehension can’t leak its i into the enclosing scope), and the trailing SWAP/STORE_FAST restore it. The ExceptionTable entry (L1 to L4 -> L5) exists precisely to guarantee that restoration even if the comprehension body raises — without it, an exception mid-comprehension would leave the enclosing i clobbered. This is a concrete case where dis reveals a compiler optimization (and the scoping guarantee it must preserve) that is completely invisible in the source. The exception table itself is the zero-cost exceptions side table.
Failure Modes and Gotchas
- Forgetting
adaptive=Trueand concluding the interpreter is “naive.” A cold disassembly shows generic opcodes; people then assume CPython never specializes. Run the code hot first and passadaptive=True. - Treating offsets as stable. Offsets shift between releases (and
CACHEslots make adjacent-instruction offsets non-contiguous). Don’t hard-code them. Use labels (is_jump_target/jump_target) for control-flow analysis. _fields≠ readable attributes. As shown above,baseopname,jump_target,oparg, etc. are properties, absent from_fields. Code that serializes anInstructionby iterating_fieldssilently drops them.- Source strings vs. objects.
dis.dis("a + b")compiles and disassembles a string; the result is a module-level expression, with a different surrounding frame setup (RESUME, noRETURN_VALUEof a real value) than the same expression inside a function. - Bytecode is version-specific. Output from 3.11, 3.12, 3.13, and 3.14 differ in opcodes (
LOAD_FAST_BORROW,LOAD_SMALL_INT,POP_ITERare 3.14-era). Pin your mental model to a version.
See Also
- Python Bytecode Instruction Set — what each opcode shown here actually does
- Code Objects — the
co_code/co_consts/co_positionscontainerdisdecodes - Bytecode Compilation — how the pipeline emits the bytecode
disreads - Inline Caches and Quickening — the
CACHEslotsshow_caches=Truereveals - The Specializing Adaptive Interpreter — why
adaptive=Trueshows different opcodes - Comprehension Scoping — the PEP 709 inlining the comprehension example exposes
- CPython Compilation Pipeline — the end-to-end source→bytecode flow
- Python Internals MOC §2 “The Compilation Pipeline”