2025, Oct 17 03:00

Understanding CPython Bytecode: Why Each Instruction Is a Fixed 2-Byte Opcode+Argument, Not Variable-Length

Learn how CPython bytecode uses fixed 2-byte instructions (opcode+argument). See dis, co_code, offsets and CACHE, and why pseudo-instructions don't appear.

When you first peek at CPython bytecode, it’s easy to think instructions are a single byte long. You dump co_code, see a stream like 97 00 7c 00 64 01…, and it looks like opcodes packed back-to-back. The documentation, however, says each instruction is 2 bytes. Where’s the extra byte hiding, and are instructions variable in length?

Reproducing the confusion

Consider a tiny function. We’ll print the raw byte stream alongside disassembly so you can see both views at once.

import dis
import sys


def probe_func(a):
    if a > 0:
        return a + 1
    return 0

print(sys.version)
print(probe_func.__code__.co_code.hex(' '))
dis.dis(probe_func)

The disassembly shows offsets 0, 2, 4, … and readable opcodes like RESUME, LOAD_FAST, LOAD_CONST, COMPARE_OP, POP_JUMP_IF_FALSE, BINARY_OP, RETURN_VALUE, RETURN_CONST. Yet the hex dump looks like single bytes per instruction at first glance.

What is actually encoded

CPython uses fixed-width instructions. Each instruction is two bytes, always an opcode byte followed by an argument byte. The opcode is the actual operation, the argument is the operand. If the operation doesn’t need an argument, the operand byte is 0x00. This matches what you see in dis, where the “offset” column marches in steps of two.

You can verify individual bytes by mapping them through dis.opname.

print(dis.opname[0x97])
print(dis.opname[0x7C])
print(dis.opname[0x64])

That prints RESUME, LOAD_FAST, LOAD_CONST, confirming the first byte in each pair is the opcode. The second byte in each pair is the argument, which is 00 when unused.

There’s also an implementation detail visible in disassembly offsets: gaps larger than 2 can appear because of a hidden CACHE instruction. If you pass show_caches=True to dis.dis, you will see those CACHE operations in the output.

Where the “longer than one byte” impression comes from

The misunderstanding often comes from scanning the byte stream one byte at a time. If you instead read co_code in pairs, the structure becomes obvious.

import itertools

pairs = itertools.batched(probe_func.__code__.co_code, 2)
for opc, arg in pairs:
    name = dis.opname[opc] if opc < len(dis.opname) else hex(opc)
    print(f"{name} {arg:02x}")

The output clearly aligns with dis: every opcode is followed by its single-byte argument, even when that argument is effectively unused.

About opcodes beyond 255 and pseudo-instructions

You might notice that dis.opname contains more than 256 entries. That doesn’t mean the interpreter emits multi-byte opcodes. Those extra entries correspond to pseudo-instructions used by the compiler during earlier stages and removed or replaced before final bytecode is produced. You won’t find them in co_code, and you won’t see them in dis output. They exist only in an intermediate representation used by the compiler. For context on their use, see https://github.com/python/cpython/blob/main/Python/flowgraph.c, and the “Pseudo-instructions” section in the dis module documentation.

Documentation note that settles it

Changed in version 3.6: Use 2 bytes for each instruction. Previously the number of bytes varied by instruction.

The operative detail is that CPython currently encodes instructions as a fixed pair of bytes: [opcode][argument]. The interpreter doesn’t need to guess instruction size from the first byte—there is always exactly one argument byte that follows.

A corrected mental model and a practical check

The correct mental model is simple: the raw bytecode stream is a sequence of opcode+argument pairs. Disassemblers print human-readable names and operands; the “offset” column is the byte offset and advances by two per instruction. Hidden CACHE entries can appear and are visible with show_caches=True.

dis.dis(probe_func, show_caches=True)

If you want to validate what’s in co_code yourself, iterate over the bytes two at a time and translate opcodes through dis.opname as shown above. This removes the illusion of single-byte instructions and lines up exactly with disassembly.

Why this matters for engineers

If you write tooling that inspects or transforms Python bytecode, or if you troubleshoot jumps and offsets, assuming one byte per instruction will lead you astray. Treating the stream as fixed-width pairs keeps offsets, control-flow targets, and instruction boundaries consistent with dis and with CPython’s documented format. It also avoids confusion around pseudo-instructions, which never make it into finalized bytecode.

Takeaways

Instructions in CPython are not variable length. Each instruction is two bytes: one opcode byte and one argument byte. The argument is 0x00 for instructions that don’t need operands. Apparent gaps in offsets can be explained by CACHE entries, which you can reveal via show_caches=True. Names beyond the 0–255 range in dis.opname refer to pseudo-instructions used only during compilation and are removed or replaced before co_code is generated. When examining co_code, always read it in two-byte steps and map the first byte through dis.opname to correctly interpret the stream.

The article is based on a question from StackOverflow by Petras Purlys and an answer by Grismar.