Disassembling Modules in Python: Complete Bytecode Analysis and Reverse Engineering Guide

Disassembling modules in python

Disassembling modules in python

I got curious about this after wondering why one version of a function I wrote ran noticeably faster than an almost-identical version. Rather than guessing, I disassembled both functions into their underlying bytecode and could see exactly which extra operations the slower version was performing. That was my introduction to Python’s dis module, and since then, bytecode analysis has become one of my favorite tools for genuinely understanding what Python is doing beneath the surface.

What Is Python Bytecode?

When I run a Python script, the source code isn’t executed directly. CPython first compiles it into an intermediate representation called bytecode — a sequence of low-level instructions for the CPython virtual machine to execute. This is similar in spirit to how Java compiles to JVM bytecode, though Python’s bytecode is specific to CPython’s own interpreter and isn’t standardized across Python implementations (PyPy, for example, doesn’t use the same bytecode internally).

This compiled bytecode is what actually gets executed by the interpreter’s evaluation loop, and it’s also what gets cached in .pyc files inside __pycache__ directories, so Python doesn’t have to recompile the same source file every time it’s imported.

Introducing the dis Module

Python’s standard library includes dis, specifically designed to disassemble compiled code objects into a human-readable listing of bytecode instructions.

import dis

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

dis.dis(add)

Output looks something like this (exact instruction names and details can vary slightly between Python versions):

  4           0 RESUME                   0

  5           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

Each line represents one bytecode instruction. Let me break down what I’m looking at:

Disassembling an Entire Module

dis.dis() can take a module, function, class, method, code object, or even a raw string of source code.

import dis
import math

dis.dis(math.sqrt)  # built-in functions are implemented in C — this will show a note about no bytecode available

For a pure-Python module, though, I can disassemble it fully:

import dis
import my_module

dis.dis(my_module)

This walks through every function and class method defined in the module and disassembles each one in turn.

Understanding Common Bytecode Instructions

Here are some of the instructions I encounter constantly, and what they mean:

The CPython Stack Machine Model

CPython’s virtual machine is a stack-based machine, not a register-based one. Every operation pushes values onto or pops values off an internal stack (specific to each function call frame). This is why so many instructions look like LOAD_FAST followed by another LOAD_FAST followed by an operation — the operands get pushed onto the stack first, and then the operation consumes them from the stack and pushes the result back.

import dis

def compute(a, b, c):
    return a * b + c

dis.dis(compute)
  4           0 RESUME                   0

  5           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                5 (*)
             10 LOAD_FAST                2 (c)
             12 BINARY_OP                0 (+)
             16 RETURN_VALUE

You can trace this like arithmetic on a stack: push a, push b, multiply (pops both, pushes result), push c, add (pops both, pushes result), return the top of the stack.

Comparing Two Implementations

This is exactly the use case that got me into bytecode analysis in the first place — comparing two functions that seem like they should behave identically.

import dis

def sum_with_loop(items):
    total = 0
    for item in items:
        total += item
    return total

def sum_with_builtin(items):
    return sum(items)

print("--- Loop version ---")
dis.dis(sum_with_loop)

print("\n--- Builtin version ---")
dis.dis(sum_with_builtin)

The loop version compiles to noticeably more instructions — setup for the loop iterator, a jump target for each iteration, the accumulation step, and a loop-back jump — compared to the builtin version, which is essentially just LOAD_GLOBAL sum, LOAD_FAST items, CALL, RETURN_VALUE. This explains, at the bytecode level, part of why built-in functions implemented in C (like sum()) tend to outperform equivalent hand-written Python loops — fewer bytecode instructions to execute, and the heavy lifting happens in optimized C code rather than the interpreter’s instruction dispatch loop.

Disassembling Compiled Code Objects Directly

Every function has a __code__ attribute holding its compiled code object, which I can inspect directly:

def example():
    x = 1
    y = 2
    return x + y

code = example.__code__
print(code.co_varnames)   # ('x', 'y')
print(code.co_consts)     # (None, 1, 2)
print(code.co_names)      # names used for global/attribute lookups

dis.dis() is really just a nicely formatted wrapper around walking through this code object’s raw bytecode (code.co_code, a bytes object) and decoding each instruction according to its opcode.

Using dis for Reverse Engineering and Understanding Third-Party Code

When I want to understand exactly what a decorator, a metaclass, or some clever piece of third-party code is doing — especially when documentation is sparse — disassembling it often reveals the actual mechanics faster than reading dense source code.

import dis

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before call")
        result = func(*args, **kwargs)
        print("After call")
        return result
    return wrapper

@my_decorator
def greet():
    print("Hello")

dis.dis(greet)  # disassembles the wrapper function, since that's what `greet` now refers to

This is also a genuinely useful technique for understanding compiled .pyc files when you don’t have the original source — though I’d note that dis disassembles bytecode into instructions, it doesn’t magically reconstruct original variable names, comments, or docstrings that weren’t preserved in the compiled form, and full source reconstruction from bytecode alone (real decompilation) requires additional third-party tools beyond what dis itself provides.

Other Useful Introspection Functions in dis

import dis

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

# Get instructions as a list of Instruction namedtuples, for programmatic analysis
instructions = list(dis.get_instructions(example))
for instr in instructions:
    print(instr.opname, instr.argval)

# Show bytecode for every function in a module, recursively
dis.dis(example)

# Just show the raw opcode names without resolving arguments
print(dis.opname[dis.HAVE_ARGUMENT])

dis.get_instructions() is what I use when I want to analyze bytecode programmatically rather than just visually inspect it — for example, writing a small script that counts how many LOAD_GLOBAL calls a function makes, as a rough proxy for how much it depends on module-level state.

Real-World Applications

Common Mistakes

Assuming bytecode is stable across Python versions. Bytecode format, opcode numbers, and even instruction names have changed between major Python releases — code that inspects co_code directly or relies on specific opcode values can break when the Python version changes.

Trying to disassemble built-in functions implemented in C and being confused when dis.dis() reports there’s no bytecode to show — C-implemented functions don’t have Python bytecode at all; they’re compiled machine code, invisible to dis.

Over-optimizing based on bytecode instruction count alone. Fewer bytecode instructions doesn’t always mean faster execution — some instructions are far more expensive than others, and real performance measurement (with timeit or a profiler) should always accompany bytecode-level reasoning, not replace it.

Debugging Tips

Performance Considerations

FAQs

Is Python bytecode the same as machine code? No. Bytecode is an intermediate representation specific to CPython’s virtual machine — it still needs to be interpreted by the CPython evaluation loop, unlike true machine code, which runs directly on the CPU.

Can I disassemble a compiled .pyc file without the original .py source? Yes, using tools that load the code object from the .pyc file and pass it to dis, though you lose comments, exact original variable names in some cases, and formatting that wasn’t preserved in bytecode.

Does every Python implementation use the same bytecode? No — bytecode is a CPython-specific implementation detail. Other implementations like PyPy or Jython handle internal execution differently, so dis output is specific to CPython.

Is analyzing bytecode a reliable way to reverse-engineer someone else’s proprietary code? It can reveal a lot about program logic, but full decompilation back to readable source is a separate, more complex process than simple disassembly, typically requiring specialized third-party decompiler tools, and you should always consider licensing and legal restrictions before reverse engineering code you don’t own.

Summary

Disassembling Python modules with the dis module pulls back the curtain on what actually happens when your code runs — every function reduces to a sequence of stack-based bytecode instructions executed by CPython’s virtual machine. Learning to read this output, understanding the stack-machine model, and recognizing common instructions like LOAD_FAST, LOAD_GLOBAL, and CALL turns abstract performance intuitions into concrete, verifiable explanations. It’s one of the most direct ways to genuinely understand Python’s internals rather than just its surface syntax.

References

Exit mobile version