Python Bytecode Explained (with Real dis Output)
Python bytecode is the low-level instruction set the Python virtual machine actually runs. When you run a .py file, CPython first compiles the source into a code object — a bundle of bytecode plus the function's constants, names, and variables — and the interpreter executes those instructions one by one. You can see it with the standard-library dis module, and it's cached to disk as a .pyc file. Bytecode is version-specific and, importantly, not a secret: it keeps your string literals in plain text and disassembles back to readable logic.
Every Python program you've ever run was executed as bytecode, even if you never thought about it. "Python is interpreted" is a bit of a simplification — there's a compile step in the middle you don't see, and understanding it explains a lot: why there's a __pycache__ folder, why a .pyc from one Python version won't load in another, and why shipping compiled Python doesn't hide your code.
Instead of describing it abstractly, I'll show you the real thing at each step — actual dis output, the real contents of a code object, and the bytes inside a .pyc — all run on Python 3.14.2. You can try snippets like these yourself in our online Python compiler, which runs real CPython in the browser.
What Python bytecode is
When CPython runs your script, it doesn't execute the text of your .py directly. It compiles the source into an intermediate form called bytecode — a compact sequence of instructions (opcodes) like "load this constant," "call this function," "return." Those instructions are then fed to the Python virtual machine (the evaluation loop inside the interpreter), which runs them.
So the real pipeline is: source `.py` → compile → bytecode (a code object) → the VM executes it. The compile step is fast and automatic, which is why it feels like Python "just runs" your source.
- Bytecode — the opcode instructions themselves (raw bytes).
- Code object — the container the compiler produces: the bytecode plus the constants, names, and variable names it references.
- `.pyc` — a code object serialized to disk so the compile step can be skipped next time.
See it yourself: the dis module
The standard library ships a disassembler. Point dis.dis() at any function and it prints the bytecode in human-readable form. Here's a small function and its real disassembly:
def add_tax(price, rate=0.2):
total = price + price * rate
return round(total, 2) 5 LOAD_FAST_BORROW_LOAD_FAST_BORROW 0 (price, price)
LOAD_FAST_BORROW 1 (rate)
BINARY_OP 5 (*)
BINARY_OP 0 (+)
STORE_FAST 2 (total)
6 LOAD_GLOBAL 1 (round + NULL)
LOAD_FAST_BORROW 2 (total)
LOAD_SMALL_INT 2
CALL 2
RETURN_VALUERead it top to bottom and it's just your function: load price and rate, multiply, add, store into total; then load round, load total and the constant 2, call, return. That's the whole point — bytecode is a faithful, low-level transcript of your code, not an encrypted version of it.
Notice opcodes like LOAD_SMALL_INT and LOAD_FAST_BORROW — those are specific to newer CPython. Older versions emit different opcodes for the same code. Hold onto that; it's why .pyc files are version-locked.
Inside a code object
The bytecode alone isn't enough to run — it references constants and names that live alongside it in the code object. You can compile a snippet and inspect them directly. I compiled a module with a fake API key and a license check:
co_consts (module): ('sk-live-42', <code object check>, None)
co_names (module): ('API_KEY', 'check')
check.co_consts : ('PRO-2026',)
check.co_varnames : ('k',)
check.co_code : 800056005200384800002300 # the raw opcode bytesLook at co_consts: the string "sk-live-42" and the license value "PRO-2026" are sitting there in plain text. co_names keeps API_KEY and check. The compiler lowered your logic to opcodes, but it did not hide your literals or your names — they're stored as data the bytecode indexes into.
And a code object is exactly what the interpreter runs — you can execute one directly with exec, no source file needed:
exec(code, ns)
ns["check"]("PRO-2026") # -> TrueThe .pyc file: bytecode on disk
When Python imports a module, it caches the compiled code object to a .pyc in __pycache__/ so it doesn't have to recompile next time. I compiled a two-line module and dissected the real file — it's 158 bytes: a 16-byte header, then one marshal-serialized code object.
total size : 158 bytes
header magic (4B) : 2b0e0d0a -> version tag 3627 (Python 3.14)
bit field (4B) : 0 (timestamp-based .pyc)
next 8 bytes : source mtime + size
rest : one marshal-serialized code objectThat first 4-byte magic number is how Python refuses to load a .pyc built by a different version. It bumps almost every release, because — as we saw — the opcodes themselves change. A few real tags:
| Python | Magic tag |
|---|---|
| 3.10 | 3439 |
| 3.11 | 3495 |
| 3.12 | 3531 |
| 3.13 | 3571 |
| 3.14 | 3627 (verified above) |
This is why a .pyc (or a marshaled bytecode blob) only runs on the exact Python version it was built for — the magic number and opcode set have to match. It's also why decompilers have to add support for each new release.
Why bytecode is not source protection
People often ship .pyc files (or marshal blobs) believing the source is now hidden. Bytecode is a *different shape*, not a *secret*. Two things I verified on the real file:
- Strings leak in the clear. Searching the raw
.pycbytes for my literals, bothSECRETand its valuehunter2are present verbatim — no decompiler required, just a text search. - The logic disassembles. As the
disoutput above shows, anyone can read the control flow and constants straight from the bytecode.
And going all the way back to source is a solved problem: a decompiler rebuilds readable .py from the bytecode. You can try it right now — drop a .pyc into our free, in-browser Python .pyc Decompiler (it runs pycdc compiled to WebAssembly, so nothing is uploaded), or read the full walk-through where I decompiled a real .pyc back to source.
Compiling to bytecode changes nothing about how exposed your code is. If the logic or the secrets inside it are valuable, you need real obfuscation — not compilation.
That's where actual protection comes in: running the source through the Python Obfuscator renames every identifier, encrypts string literals so they don't sit in co_consts as plain text, and can wrap the whole thing in an encrypted loader — so even the disassembled bytecode gives an attacker nothing readable.
Decompile a .pyc in your browser — free
See how little bytecode hides: drop a compiled .pyc in and get readable source back. Runs client-side, nothing uploaded.
Open the .pyc DecompilerFree tools mentioned here
Frequently asked questions
What is Python bytecode?
Python bytecode is the low-level instruction set (opcodes) that the Python virtual machine executes. CPython compiles your source into a code object containing bytecode plus its constants and names, and the interpreter runs those instructions. You can view it with the standard-library dis module.
How do I see the bytecode of a Python function?
Use the dis module: import dis, then call dis.dis(your_function). It prints each opcode with its argument and a human-readable note, so you can read the compiled instructions directly.
Why won’t a .pyc file run on a different Python version?
Because each .pyc starts with a 4-byte magic number tied to a specific CPython version, and the opcode set itself changes between releases (e.g. Python 3.14 adds opcodes like LOAD_SMALL_INT). If the magic number doesn't match, Python refuses to load the file rather than risk running incompatible bytecode.
Does Python bytecode hide my source code?
No. Bytecode keeps string literals in plain text (they live in co_consts and are readable in the raw .pyc bytes) and disassembles back to readable logic with the dis module. Decompilers rebuild near-original source from it. Bytecode is a cache, not protection.
What is a code object in Python?
A code object is the container the compiler produces for a module or function. It holds the bytecode (co_code) plus the constants (co_consts), global names (co_names), and local variable names (co_varnames) the bytecode references. You can execute one directly with exec().