Skip to main content

Python Bytecode Compilation

Explore CPython bytecode compilation from source to .pyc files. Learn the dis module, PVM stack operations, and Python 3.11+ adaptive specialization.

What is Python Bytecode?

Python bytecode is an intermediate representation of Python source code. When you run a Python program, it's first compiled to bytecode, which is then executed by the Python Virtual Machine (PVM).

Python Bytecode Visualization

Choose a source example, then follow its instruction order and inspect the value stack.

Step 1 / 12
simple.py
def add(a, b):    return a + bresult = add(3, 5)

Bytecode Instructions

0LOAD_CONST0(<code object add>)
2LOAD_CONST1('add')
4MAKE_FUNCTION0
6STORE_NAME0(add)
8LOAD_NAME0(add)
10LOAD_CONST2(3)
12LOAD_CONST3(5)
14CALL_FUNCTION2
16STORE_NAME1(result)
18LOAD_CONST4(None)
20RETURN_VALUE

Value Stack

Ready. Play or Next begins the instruction trace.

How Python Bytecode Works

  • Python compiles source code to bytecode before execution
  • Bytecode is platform-independent and cached in .pyc files
  • The Python Virtual Machine (PVM) executes bytecode instructions
  • Each instruction manipulates the value stack and local/global namespaces
Authored teaching traces use legacy CPython opcodes. Opcode names, offsets and function creation differ across Python versions; these are not disassembly captured from your interpreter. Playback walks the listing once rather than interpreting jumps or nested function bodies.

The Compilation Process

1. Source Code to Tokens

Python first tokenizes your source code:

python
# Source code
def add(a, b):
    return a + b

# Tokens
NAME 'def'
NAME 'add'
OP '('
NAME 'a'
OP ','
NAME 'b'
OP ')'
OP ':'
# ...

2. Tokens to AST

The tokens are parsed into an Abstract Syntax Tree:

python
import ast
import inspect

def add(a, b):
    return a + b

# Get the AST
tree = ast.parse(inspect.getsource(add))
print(ast.dump(tree))

3. AST to Bytecode

The AST is compiled to bytecode instructions:

python
import dis

def add(a, b):
    return a + b

dis.dis(add)
# Output:
#   2           0 LOAD_FAST                0 (a)
#               2 LOAD_FAST                1 (b)
#               4 BINARY_ADD
#               6 RETURN_VALUE

Understanding Bytecode Instructions

Common Instructions

InstructionDescriptionStack Effect
LOAD_FASTLoad local variablePush value
LOAD_GLOBALLoad global variablePush value
STORE_FASTStore to local variablePop value
BINARY_ADDAdd two valuesPop 2, push 1
CALL_FUNCTIONCall a functionPop args+func, push result
RETURN_VALUEReturn from functionPop value
POP_TOPRemove top of stackPop 1
JUMP_IF_FALSEConditional jumpCheck top

The Value Stack

Python's VM is stack-based. Operations manipulate a value stack:

python
# Expression: a + b * c
# Bytecode execution:

LOAD_FAST    0 (a)    # Stack: [a]
LOAD_FAST    1 (b)    # Stack: [a, b]
LOAD_FAST    2 (c)    # Stack: [a, b, c]
BINARY_MUL            # Stack: [a, b*c]
BINARY_ADD            # Stack: [a+b*c]

Once bytecode exists, interpreter dispatch selects the handler for each instruction. Python 3.14’s tail-call interpreter changes that C-level dispatch path while preserving Python function-call semantics.

Bytecode Caching

.pyc Files

Python caches compiled bytecode in __pycache__ directories:

bash
mymodule.py
__pycache__/
    mymodule.cpython-311.pyc  # Python 3.11 bytecode

Cache Validation

Cache validation controls recompilation

Python checks whether the source has changed, using modification timestamps or a source hash (Python 3.7+), and recompiles if needed.

Inspecting Bytecode

Using the dis Module

python
import dis

# Disassemble a function
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

dis.dis(factorial)

Bytecode Objects

python
# Access bytecode directly
code = factorial.__code__

print(f"Argument count: {code.co_argcount}")
print(f"Local variables: {code.co_nlocals}")
print(f"Stack size: {code.co_stacksize}")
print(f"Constants: {code.co_consts}")
print(f"Variable names: {code.co_varnames}")
print(f"Bytecode: {code.co_code.hex()}")

Control Flow in Bytecode

Conditional Execution

python
def check_positive(x):
    if x > 0:
        return "positive"
    return "non-positive"

# Bytecode uses jumps:
# LOAD_FAST        0 (x)
# LOAD_CONST       1 (0)
# COMPARE_OP       4 (>)
# POP_JUMP_IF_FALSE to 8
# LOAD_CONST       2 ('positive')
# RETURN_VALUE
# LOAD_CONST       3 ('non-positive')
# RETURN_VALUE

Loops

python
def sum_range(n):
    total = 0
    for i in range(n):
        total += i
    return total

# Loop bytecode uses:
# GET_ITER
# FOR_ITER
# JUMP_ABSOLUTE (back to loop start)

Python 3.11+ Improvements

Adaptive Bytecode

Python 3.11 specializes bytecode based on runtime behavior:

python
def add_numbers(a, b):
    return a + b

# First calls: BINARY_ADD (generic)
# After ~8 int additions: BINARY_ADD_INT (specialized)

Inline Caching

Frequently accessed attributes are cached inline:

python
# Before: LOAD_ATTR requires dictionary lookup
# After: LOAD_ATTR_SLOT uses cached offset

Performance Implications

What's Fast

  • Local variable access (LOAD_FAST)
  • Built-in operations
  • Specialized bytecode (3.11+)

What's Slow

  • Global variable access (LOAD_GLOBAL)
  • Attribute lookup (LOAD_ATTR)
  • Function calls (CALL_FUNCTION)

Practical Example

python
# Original code
def process_list(items):
    result = []
    for item in items:
        if item > 0:
            result.append(item * 2)
    return result

# More efficient (fewer bytecode instructions)
def process_list_optimized(items):
    return [item * 2 for item in items if item > 0]

If you found this explanation helpful, consider sharing it with others.

Mastodon