Python compiles your source to bytecode — a compact instruction set for its stack-based virtual machine — before running it. The standard-library `dis` module disassembles it back to a readable listing: dis.dis(obj) prints each opcode, its argument, and the source line it came from. Paste code into the Bytecode Disassembler to explore it interactively across Python 3.11–3.13, or run python -m dis file.py locally. One thing the disassembly makes obvious: every string literal sits in the bytecode in plain text, so .pyc is not protection.
When you run a .py file, CPython doesn't execute your source directly. It first compiles it into bytecode — a low-level instruction set — and runs *that* on a virtual machine. The dis module lets you look at exactly those instructions, which is the fastest way to understand what Python really does, debug a surprising behaviour, or see how little a compiled file actually hides.
I ran every example below on Python 3.14 (and cross-checked the version differences), so the opcodes you see here are real output, not paraphrased. Let's disassemble some code.
What Python bytecode actually is
CPython compiles each function, class, and module into a code object — a bundle holding the bytecode plus its constants, names, and metadata. That bytecode is a sequence of opcodes (one-byte instructions) that push and pop values on an evaluation stack. It's what gets cached in the __pycache__/*.pyc files, and it's what the interpreter loop actually executes.
You don't normally see it because it's an implementation detail — but it's fully inspectable, and reading it demystifies a lot of Python. The tool for that is dis.
Disassembling with dis: the one-liner
Pass any function to dis.dis() and it prints the disassembly. Here's a trivial adder, disassembled on Python 3.14 (real output):
import disdef add(a, b):return a + bdis.dis(add)# 5 RESUME 0## 6 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)# BINARY_OP 0 (+)# RETURN_VALUE
Read the columns left to right: the source line number (6), the opcode (BINARY_OP), its argument (0), and a human-readable argrepr in parentheses ((+)). RESUME is a no-op marker CPython places at the start of every frame.
That LOAD_FAST_BORROW_LOAD_FAST_BORROW is a superinstruction new in Python 3.14 — it fuses what used to be two separate LOAD_FAST a / LOAD_FAST b instructions. On Python 3.13 and earlier you'll see the two loads separately. Bytecode is version-specific, which is a theme we'll come back to.
Reading the opcodes
Most opcodes are named for exactly what they do. A handful cover the vast majority of real code:
| Opcode | What it does |
|---|---|
LOAD_FAST | Push a local variable onto the stack |
LOAD_CONST | Push a constant from co_consts |
LOAD_GLOBAL | Push a global or builtin |
STORE_FAST | Pop the top of stack into a local |
BINARY_OP | Apply + - * % … to the top two values |
COMPARE_OP | Compare the top two (<, ==, …) |
CALL | Call a callable with N arguments |
RETURN_VALUE | Return the top of stack to the caller |
POP_JUMP_IF_FALSE | Pop, and jump if it was falsey |
f-strings are a nice example of the compiler doing work up front. This function builds a greeting:
def greet(name):return f"Hello, {name}!"# 10 LOAD_CONST 0 ('Hello, ')# LOAD_FAST_BORROW 0 (name)# FORMAT_SIMPLE# LOAD_CONST 1 ('!')# BUILD_STRING 3# RETURN_VALUE
There's no runtime string parsing: the literal parts 'Hello, ' and '!' are already constants, the variable is formatted with FORMAT_SIMPLE, and BUILD_STRING 3 concatenates the three pieces. (In older versions this opcode was FORMAT_VALUE — another version difference.)
Every function is its own code object
The real structure shows up with closures. Here make_counter returns an inner function inc that mutates a captured variable — so the compiler has to set up cells (co_cellvars) and free variables (co_freevars):
def make_counter(start=0):count = startdef inc(step=1):nonlocal countcount += stepreturn countreturn inc# make_counter:# MAKE_CELL 2 (count)# ...# STORE_DEREF 2 (count)# LOAD_CONST 1 (<code object inc at 0x…, line 22>)# MAKE_FUNCTION# SET_FUNCTION_ATTRIBUTE 8 (closure)## Disassembly of <code object inc …>:# COPY_FREE_VARS 1# LOAD_DEREF 1 (count)# LOAD_FAST_BORROW 0 (step)# BINARY_OP 13 (+=)# STORE_DEREF 1 (count)# LOAD_DEREF 1 (count)# RETURN_VALUE
Notice the nested <code object inc> — it's stored right inside the outer function's co_consts. dis recurses into it automatically. MAKE_CELL, STORE_DEREF, COPY_FREE_VARS, and LOAD_DEREF are the closure machinery: the shared count lives in a cell so both functions see the same variable.
The online Bytecode Disassembler renders each of these code objects as its own collapsible panel, with the cellvars, freevars, consts, and names tables laid out — so you can see the closure wiring without scrolling through a flat text dump.
Bytecode changes every Python version (a real test)
This is the part people trip over: bytecode is not stable across releases. The exact same source compiles to different instructions on different Pythons. A few concrete, verifiable changes:
- 3.11 introduced the adaptive specializing interpreter and inline
CACHEslots — you'll seeCACHEentries between real ops when you show them. - 3.12 inlined list/set/dict comprehensions (no separate
<listcomp>code object anymore) and addedRETURN_CONST. - 3.14 fused loads into superinstructions like
LOAD_FAST_BORROW_LOAD_FAST_BORROW, as we saw above.
I checked the comprehension change directly. On Python 3.14, a list comprehension compiles with no nested code object — it's inlined into the surrounding code:
code = compile("squares = [n*n for n in range(10) if n % 2 == 0]","<demo>", "exec")print([type(c).__name__ for c in code.co_consts])# -> ['int', 'NoneType'] # no <code object <listcomp>>print(any(hasattr(c, 'co_code') for c in code.co_consts))# -> False
On Python 3.11 that same line produces a separate <listcomp> code object in co_consts. This is exactly why the disassembler tool lets you switch the CPython version (3.11 / 3.12 / 3.13) — you can watch the structure change instead of taking my word for it.
Why this matters: bytecode is not protection
Reading bytecode makes one security fact undeniable: a compiled Python file gives your code away. Every string and number literal is stored, in plain text, in the code object's co_consts. Here's a file with an "embedded secret", compiled and inspected — no decompiler needed:
src = 'API_KEY = "sk-live-9f83kd02n"\nif user == "admin":\n grant()\n'code = compile(src, "app.py", "exec")code.co_consts# -> ('sk-live-9f83kd02n', 'admin', None)code.co_names# -> ('API_KEY', 'user', 'grant')
The API key, the string it's compared against, and every name are all sitting in the open — and that's *before* anyone runs a decompiler like pycdc to rebuild the source. Shipping .pyc files (or freezing to an .exe, which just bundles .pyc) hides none of this.
If your code holds anything worth protecting — keys, licensing logic, a proprietary algorithm — obfuscate the source first so the constants and names are encrypted or mangled, *then* the bytecode a reverse-engineer disassembles is meaningless. That's what the Python Obfuscator is for.
Explore your own bytecode
Paste any Python and watch it compile to CPython bytecode — interactive opcodes, code objects and jumps, across Python 3.11–3.13. Nothing runs, nothing is uploaded.
Open the Bytecode DisassemblerFree tools mentioned here
Related guides
Frequently asked questions
How do I disassemble Python bytecode?
Use the standard-library dis module: import dis and call dis.dis(obj) on a function, class, module, or code object to print its bytecode. From the command line, run python -m dis your_file.py. Each line shows the source line number, the opcode, its argument, and a readable value. Online, paste code into pyobfuscate.com's Bytecode Disassembler to explore it interactively without installing anything.
What is Python bytecode?
Python bytecode is the low-level instruction set CPython compiles your source into before running it. Instead of executing your .py text directly, CPython compiles each function and module into a code object containing bytecode — opcodes that manipulate a value stack — and runs that on its virtual machine. The compiled bytecode is cached in __pycache__/*.pyc files.
What is the dis module?
dis is Python's built-in disassembler. It turns compiled bytecode back into a human-readable listing of opcodes with their arguments, source lines, and jump targets. dis.dis(x) disassembles almost anything — a function, method, class, string of source, or raw code object — and recurses into nested code objects like inner functions and comprehensions.
Why is my bytecode different on another Python version?
CPython's bytecode is an internal detail that changes between releases to make the interpreter faster. For example, comprehensions were inlined in 3.12, RETURN_CONST was added in 3.12, 3.11 introduced adaptive CACHE entries, and 3.14 added load superinstructions. The same source therefore disassembles differently on 3.11, 3.12, 3.13 and 3.14 — which is normal.
Does compiling to bytecode protect my source code?
No. Every string and number literal is stored in the code object's co_consts in plain text, and the logic is visible in the opcodes, so bytecode reveals almost everything even before a decompiler rebuilds the source. To protect code, obfuscate the source first (rename identifiers, encrypt strings) so the bytecode is meaningless, and optionally compile to a native module.