What are .pyc files and what role does Python bytecode play?
When Python imports a module, it compiles the source to platform-independent bytecode and caches it in a .pyc file inside __pycache__. On subsequent imports the cached bytecode is loaded directly if the source is unchanged, skipping the parse-and-compile step. Bytecode is not machine code — it is still interpreted by the CPython virtual machine.
How to think about it
This question is really about everything that happens between you saving a .py file and the CPU running it. There are two layers to the answer: the compilation step that turns source into bytecode, and the caching step — the .pyc file — that avoids redoing that compilation on every import.
The compilation pipeline
Each time CPython imports a module, your source runs through four stages:
source.py
│ Tokeniser → tokens (keywords, names, literals, operators)
│ Parser → AST (abstract syntax tree)
│ Compiler → bytecode (a code object)
▼ CPython VM → executes the instructions
The compiler’s output — a marshalled code object — is what gets saved to __pycache__/source.cpython-312.pyc. The interpreter version is baked into the filename, so caches from different Python releases never collide.
What bytecode looks like
dis lets you see it:
import dis
def add(a, b):
return a + b
dis.dis(add)
# RESUME 0
# LOAD_FAST 0 (a)
# LOAD_FAST 1 (b)
# BINARY_OP 0 (+)
# RETURN_VALUE
These are stack-machine instructions, run by the CPython virtual machine — not native machine code. That’s the whole reason Python is slower than C in a tight loop yet fast enough for almost everything real: there’s an interpreter standing between the bytecode and the CPU. (The exact opcodes shift between releases — this is recent-3.x output — which is precisely why the cache is version-stamped.)
What’s inside a .pyc
A .pyc is a small binary header followed by that marshalled code object:
bytes 0–3 magic number (changes whenever a release changes bytecode)
bytes 4–7 bit field (0 = timestamp-based, 1 = hash-based invalidation)
bytes 8–15 source mtime + size (or a hash, if hash-based)
bytes 16+ the marshalled code object
The magic number is the guard that stops a 3.11 interpreter from loading a 3.12 .pyc by accident.
Invalidation and source-free deploys
By default CPython regenerates the .pyc when the source’s mtime or size changes; you can switch to content-hash invalidation with --check-hash-based-pycs always. And you can ship only .pyc files — the interpreter will run them — pre-compiling a whole tree with:
python -m compileall src/
Just don’t mistake that for security: bytecode decompiles trivially. It only prevents accidental edits on a production host.