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

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:

  • The leftmost number is the source line number that instruction corresponds to.
  • The next number is the byte offset of that instruction within the compiled code object.
  • The instruction name (LOAD_FAST, BINARY_OP, RETURN_VALUE) tells the virtual machine what operation to perform.
  • The number after the instruction name (when present) is an argument — often an index into a table of local variable names, constants, or names.
  • The parenthesized value is dis helpfully resolving that index into something readable, like the actual variable name or constant value.

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:

  • LOAD_FAST / STORE_FAST: load or store a local variable, using a fast array-indexed lookup rather than a dictionary lookup (this is why local variable access is faster than global variable access in Python).
  • LOAD_GLOBAL: look up a name in the global (module-level) namespace, which is a dictionary lookup and therefore slower than LOAD_FAST.
  • LOAD_CONST: push a constant value (already known at compile time) onto the stack.
  • CALL: call a callable object with a given number of arguments (the exact instruction name and argument-passing convention has changed across Python versions as the interpreter has evolved).
  • BINARY_OP: perform a binary operation (addition, subtraction, etc.) on the top two stack values, with the specific operation encoded as an argument.
  • RETURN_VALUE: return the top of the stack as the function’s result.
  • JUMP_FORWARD / POP_JUMP_IF_FALSE: control flow instructions implementing loops and conditionals.

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

  • Performance debugging: comparing bytecode between two implementations to understand why one is faster, as shown above.
  • Understanding language internals and version differences: bytecode has changed meaningfully across Python versions (the 3.11 release, for instance, introduced significant changes to how function calls and adaptive specialization work internally), and disassembly is the most direct way to see those changes concretely.
  • Security research and malware analysis: analyzing compiled .pyc files when source isn’t available, to understand what a piece of code actually does.
  • Educational tooling: teaching how Python actually executes code, moving students beyond a purely textual mental model of “the interpreter just runs my code” toward understanding compilation and execution as distinct phases.
  • Debugging obscure behavior in decorators, metaclasses, or comprehensions, where the actual generated bytecode can clarify subtleties like variable scoping in list comprehensions.

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

  • Use dis.dis(some_function) early when you suspect two seemingly-equivalent code paths perform differently — the instruction listing often reveals the difference immediately (an unexpected global lookup, an unnecessary intermediate copy, extra function call overhead).
  • Combine dis with timeit — disassemble first for a mechanical explanation of why something might be slow, then measure to confirm the actual real-world impact.
  • Check sys.version_info in your scripts before relying on dis output examples from documentation or blog posts, since exact instruction names and formats do shift across versions.

Performance Considerations

  • dis.dis() itself is a diagnostic tool — it doesn’t change or optimize the code it inspects, and running it has no runtime performance impact on the code being analyzed.
  • Fewer, cheaper bytecode instructions generally correlate with faster execution, but the relationship isn’t strictly linear — some single instructions (like a function call) carry substantially more overhead than others (like a local variable load).
  • Understanding that LOAD_FAST (local variables) is cheaper than LOAD_GLOBAL (global lookups) is one of the most directly actionable performance insights bytecode analysis can offer — it’s part of why moving frequently-accessed global values into local variables inside a hot loop is a known micro-optimization technique.

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

Total
0
Shares

Leave a Reply

Previous Post
Random Binary Decision in python

Random Binary Decision in Python: Complete Random Choice and Decision Making Implementation Guide

Next Post
Encoding and Decoding Base64 in python

Encoding and Decoding Base64 in Python: Complete Binary-to-Text Encoding Implementation Guide

Related Posts