Python dis Module: Disassembling Bytecode Explained

This article explores how Python's built-in dis module translates raw, binary bytecode into human-readable instructions. It covers Python’s compilation pipeline, the internal structure of code objects, the mapping of numeric opcodes to human-readable names, and how argument references are resolved into variables, constants, and line numbers.

From Source Code to Bytecode

Before Python executes code, it compiles the source text into an intermediate representation known as bytecode. The source code is first parsed into an Abstract Syntax Tree (AST), which the compiler evaluates to produce a code object.

A code object contains the raw execution instructions alongside metadata required by the Python Virtual Machine (PVM). Key attributes stored in a code object include:

The Structure of Bytecode Instructions

Starting with Python 3.6, Python uses a 16-bit "wordcode" format for its instruction set. Each instruction consists of two bytes:

  1. Opcode (1 byte): A number between 0 and 255 representing the specific operation (e.g., load a value, perform addition, return).
  2. Argument / Oparg (1 byte): A numeric index or value between 0 and 255 used by the operation. If an instruction requires an argument larger than 255, Python prepends an EXTENDED_ARG opcode to expand the value.

For instructions that do not require an argument, the argument byte is set to zero and ignored by the virtual machine.

How the dis Module Reads and Translates Bytes

The dis module acts as a decoder for the bytes stored in co_code. When you pass a function, method, or code object to dis.dis(), the module performs several sequential steps to format the data into readable text.

1. Decoding Opcodes

The dis module iterates through co_code two bytes at a time. It takes the first byte (the numeric opcode) and maps it to a string name using the internal lookup table opcode.opname.

For example, if the first byte is the integer 100, the module looks up index 100 in opname, which returns the string 'LOAD_CONST'.

2. Resolving Arguments

The second byte represents the raw argument index. Different opcodes interact with different lookup tables in the code object. The dis module determines the type of argument based on opcode categories:

When dis detects one of these opcodes, it fetches the actual value or name from the appropriate tuple and formats it alongside the raw argument.

3. Mapping Line Numbers

The module reads the co_linetable (or co_lnotab in versions prior to Python 3.11) to determine which line of source code generated the instruction. It checks the current byte offset against this mapping to determine when a new line begins.

Understanding the Disassembled Output

When dis.dis() outputs the results, it formats each instruction into distinct columns:

  1           0 LOAD_CONST               1 ('hello')
              2 STORE_FAST               0 (message)
              4 LOAD_CONST               0 (None)
              6 RETURN_VALUE

The columns represent:

  1. Source Line Number (e.g., 1): The line of the original Python script where the operation originates. It only appears on the first instruction generated by that line.
  2. Instruction Offset (e.g., 0, 2, 4, 6): The byte index of the instruction within co_code. Because instructions are two bytes each, these increment by 2 (or more if EXTENDED_ARG or inline caches are present in newer versions).
  3. Opcode Name (e.g., LOAD_CONST, STORE_FAST): The human-readable string mapped from the opcode byte.
  4. Raw Argument (e.g., 1, 0): The literal numerical value of the argument byte.
  5. Resolved Argument (e.g., ('hello'), (message)): The human-readable interpretation derived by dis querying co_consts, co_varnames, or co_names.

By unpacking binary pairs into operation names and cross-referencing argument indices with the code object’s internal metadata tables, the dis module provides full transparency into how the Python Virtual Machine executes code.