CPython VM, Bytecode, and Code Execution

Updated

September 7, 2026

CPython VM, Bytecode, and Code Execution

After reading this chapter, you will master CPython’s internal compilation pipeline, disassemble Python functions into bytecode using the dis module, analyze frame evaluation stacks, understand modern specialization opcodes (PEP 659 adaptive interpreter), and trace how CPython executes instructions.

Mental model

Python is neither a pure interpreter nor a pure machine-code compiler. It is a bytecode-compiled stack virtual machine:

CPython Execution Pipeline:
  Python Source Code ("result = a + b")
           │
           ▼ 1. Tokenizer & Parser
  Abstract Syntax Tree (AST)
           │
           ▼ 2. Bytecode Compiler
  Code Object (PyCodeObject: co_code, co_consts, co_varnames)
           │
           ▼ 3. Evaluation Loop (ceval.c / bytecodes.c)
  Allocates PyFrameObject on C call stack:
    ┌───────────────────────────┐
    │ Value Stack (LIFO stack)  │ ──▶ PUSH a ──▶ PUSH b ──▶ BINARY_OP (+) ──▶ STORE result
    │ Fast Local Array (locals) │
    └───────────────────────────┘

The CPython virtual machine evaluates bytecode instructions by pushing operands onto and popping operands off an in-memory evaluation value stack.


Minimal example

Save as disassemble_bytecode.py:

# disassemble_bytecode.py
import dis

def calculate_tax(amount: float, rate: float = 0.08) -> float:
    total = amount * (1.0 + rate)
    return total

def main() -> None:
    print("--- Disassembling calculate_tax function ---")
    dis.dis(calculate_tax)

    code_obj = calculate_tax.__code__
    print("\n--- Code Object Introspection ---")
    print(f"Argument count     : {code_obj.co_argcount}")
    print(f"Local variables    : {code_obj.co_varnames}")
    print(f"Constants table    : {code_obj.co_consts}")
    print(f"Stack size required: {code_obj.co_stacksize}")

if __name__ == "__main__":
    main()

Run via uv run python disassemble_bytecode.py:

--- Disassembling calculate_tax function ---
  ... LOAD_FAST            0 (amount)
  ... LOAD_CONST           1 (1.0)
  ... LOAD_FAST            1 (rate)
  ... BINARY_OP            0 (+)
  ... BINARY_OP            5 (*)
  ... STORE_FAST           2 (total)
  ... LOAD_FAST            2 (total)
  ... RETURN_VALUE

--- Code Object Introspection ---
Argument count     : 2
Local variables    : ('amount', 'rate', 'total')
Constants table    : (None, 1.0)
Stack size required: 3

Worked examples

Case 1: The Adaptive Specializing Interpreter (PEP 659)

Modern CPython versions (3.11+) feature a specializing, adaptive interpreter. When an instruction (like BINARY_OP or LOAD_ATTR) executes repeatedly with the exact same types, CPython dynamically swaps the generic opcode for a type-specialized opcode (e.g. BINARY_OP_ADD_INT):

# adaptive_specialization.py
import dis

def add_integers(a: int, b: int) -> int:
    return a + b

def main() -> None:
    # 1. Warm up the function to trigger adaptive specialization in the VM
    for i in range(10_000):
        add_integers(i, 1)

    print("Disassembly after adaptive specialization:")
    # adaptive=True reveals specialized opcodes
    dis.dis(add_integers, adaptive=True)

if __name__ == "__main__":
    main()

Run:

uv run python adaptive_specialization.py

Output:

Disassembly after adaptive specialization:
  ... LOAD_FAST                0 (a)
  ... LOAD_FAST                1 (b)
  ... BINARY_OP_ADD_INT        0 (+)
  ... RETURN_VALUE

Notice that the generic BINARY_OP was dynamically replaced by the CPython VM with BINARY_OP_ADD_INT, skipping generic type checks and achieving near-C integer addition speeds.

Case 2: Inspecting Call Stack Frames with sys._getframe()

Each function call allocates an in-memory frame (PyFrameObject) tracking execution state:

# frame_inspector.py
import sys

def deep_worker(level: int) -> None:
    current_frame = sys._getframe(0)
    caller_frame  = sys._getframe(1)

    print(f"Level {level} Frame Info:")
    print(f"  Current function : {current_frame.f_code.co_name}")
    print(f"  Current line     : {current_frame.f_lineno}")
    print(f"  Local variables  : {current_frame.f_locals}")
    print(f"  Caller function  : {caller_frame.f_code.co_name}")

def orchestrator() -> None:
    deep_worker(42)

if __name__ == "__main__":
    orchestrator()

Run:

uv run python frame_inspector.py

Output:

Level 42 Frame Info:
  Current function : deep_worker
  Current line     : 6
  Local variables  : {'level': 42}
  Caller function  : orchestrator

Pitfalls

Pitfall 1: Modifying Code Objects at Runtime

PyCodeObject instances are immutable C-level structs. Attempting to assign to func.__code__.co_code raises AttributeError: readonly attribute. To modify bytecode dynamically, you must instantiate a new CodeType object via types.CodeType.


Exercises

  1. Disassemble a list comprehension and compare its bytecode against an equivalent explicit for loop.
  2. Use dis.get_instructions() to programmatically scan a function’s bytecode and count the number of LOAD_GLOBAL instructions.
  3. Compare the bytecode of string concatenation (a + b) versus an f-string (f"{a}{b}").
  4. Inspect __code__.co_flags and determine whether a function is a standard function, a generator, or a coroutine.

Further reading

  • PEP 659: Specializing Adaptive Interpreter.
  • Python Standard Library: dis module documentation.
  • CPython Source: Python/ceval.c and Python/bytecodes.c.