The dis Module and Bytecode Disassembly

The standard-library dis module 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 structured Instruction records, and the dis.Bytecode class 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. dis is 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 between RESUME and LOAD_GLOBAL is dis’s way of grouping instructions by source line.
  • Opcode name (RESUME, LOAD_GLOBAL…): the opname, left-justified to 20 characters.
  • Argument (0, 1, 0, 1): the raw integer oparg, right-justified to 5 characters. For RETURN_VALUE (no argument) the column is blank.
  • argval interpretation ((len + NULL), (alist)): the human-readable resolution of the argument. LOAD_GLOBAL 1 resolves to the name len (index 1 in co_names); the + NULL annotation means the opcode also pushes a NULL for the upcoming call convention. LOAD_FAST_BORROW 0 resolves to the local alist (index 0 in co_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 for arg.
  • baseopname / baseopcode — the generic op name/number when this instruction is a specialized variant (e.g. LOAD_ATTR_INSTANCE_VALUE → base LOAD_ATTR); otherwise equal to opname/opcode. (Added 3.13.)
  • cache_offset (self.offset + 2) and end_offset — the byte range of the trailing cache entries.
  • jump_target — the destination bytecode index if this is a jump, else None.
  • is_jump_targetTrue iff self.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_VALUE

Each 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:

  • RESUMERESUME_CHECK (the no-eval-breaker fast path).
  • FOR_ITERFOR_ITER_LIST (specialized for iterating a list).
  • BINARY_OPBINARY_OP_ADD_INT (specialized for int + int, skipping the generic dispatch through __add__).
  • LOAD_FAST_BORROW + LOAD_FAST_BORROW fused into LOAD_FAST_BORROW_LOAD_FAST_BORROW (a super-instruction loading two locals at once).
  • LOAD_SMALL_INT (new in 3.14) replacing LOAD_CONST for the small integer 0.

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=True and concluding the interpreter is “naive.” A cold disassembly shows generic opcodes; people then assume CPython never specializes. Run the code hot first and pass adaptive=True.
  • Treating offsets as stable. Offsets shift between releases (and CACHE slots 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 an Instruction by iterating _fields silently 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, no RETURN_VALUE of 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_ITER are 3.14-era). Pin your mental model to a version.

See Also