~/TechPurAI
~/tutorials/python-compiler-vs-interpreter
intermediate·standalone·6 min read

Python compiler vs. interpreter: what actually happens when you run a .py file

Updated Aug 31, 2026Python

Ask most people whether Python is compiled or interpreted and you'll get "interpreted" without hesitation. That answer is incomplete. Every time you run a .py file, Python compiles it first — you just never see the compiler running, because it isn't a separate command you invoke. It happens automatically, in a fraction of a second, and produces something most tutorials never show you.

The compile step you never see

python file.py actually does two things: compile, then run. You can trigger just the first half yourself:

bash
python -m py_compile hello.py
ls __pycache__/
text
hello.cpython-312.pyc

That .pyc file is the output of Python's compiler. It isn't machine code your CPU can execute directly — it's bytecode, a lower-level instruction set for the Python virtual machine (the part of the CPython interpreter that actually runs your program) to execute. Compiling to bytecode first, then interpreting the bytecode, is the two-step process hiding behind "just run the file."

What's actually inside the bytecode

The dis module disassembles a function into its bytecode instructions directly, no .pyc file needed:

python
import dis

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

dis.dis(add)
text
  4           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_OP                0 (+)
              8 RETURN_VALUE

Compare that to the source: return a + b is one readable line. The bytecode is four flat, tiny instructions — load a value, load another, add them, return the result. That gap is exactly what a compiler's job is: turning something readable into something a simpler machine (here, a virtual one) can execute step by step without any of its own parsing or interpretation logic.

Common misconception

"Interpreted language" doesn't mean "no compiler." True line-by-line interpreters that re-parse source on every run are rare in practice. Python, like most languages people call "interpreted," compiles to an intermediate form first and interprets that — the real distinction people mean is "not compiled ahead of time to native machine code for a specific CPU," not "has no compiler at all."

Why the cache exists, and when it goes stale

Python doesn't recompile a module every time you import it. __pycache__ holds the compiled bytecode, and Python compares it against the source file's modification time (or a content hash, depending on version and settings) to decide whether to reuse the cached version or recompile.

bash
touch hello.py          # bumps the modification time
python -m py_compile hello.py

That's why "delete __pycache__ and try again" occasionally fixes a genuinely confusing bug — in rare cases (system clocks skewing, files copied in a way that doesn't preserve timestamps) the cache invalidation check gets it wrong, and Python runs bytecode compiled from an older version of your source. It's not a superstition; it's clearing a cache that's specifically keyed on a heuristic that can, occasionally, be wrong.

What an actually compiled language's build step looks like

The contrast is clearest side by side. Compiling a small C program produces a file the CPU runs directly, with no interpreter involved at any point after this step:

bash
gcc hello.c -o hello
./hello

gcc translates hello.c all the way to native machine code for the specific CPU architecture it's run on — that hello file is not portable to a different CPU architecture, and running it requires no separate runtime to be installed on the machine that executes it. Compare that to Python's .pyc bytecode from earlier: it's portable across any machine with a matching CPython version, but it can never be executed directly by a CPU — the interpreter is required, every time, forever. That's the actual distinction underneath "compiled vs. interpreted": whether the compile step's output is something a CPU runs directly, or something a separate program still has to read and execute.

Why this matters beyond trivia

This two-step process is also why Python can't ship as a single portable binary the way the gcc-compiled program above can. Compilation to bytecode happens on the machine running the code, every time (modulo the cache) — there's no ahead-of-time step that produces something a CPU executes directly. Tools like PyInstaller work around this by bundling a Python interpreter and your compiled bytecode into one package, not by compiling your code to native machine code.

Where the line gets blurrier

CPython has been experimenting with an actual JIT in recent versions — compiling the hottest bytecode further into real machine code at runtime, for the parts of a program that run often enough to be worth it. Other implementations go further still: PyPy compiles Python to machine code as it runs, and can be dramatically faster on long-running programs for exactly that reason. "Python is slow because it's interpreted" is a real historical pattern, not a permanent law.

If you're here from a search for an online Python compiler

Everything above works in any Python environment, including a browser-based one — py_compile and dis are both standard library, no install beyond having a Python shell open somewhere. If you don't have Python installed locally, python.org's own installer takes a few minutes and gets you the actual python and dis tools this tutorial uses, not just a place to paste code.

FAQ

If Python has a compiler, why do people still call it interpreted? Because the practically important difference for a Python developer — no separate build step you run yourself, no native binary to distribute — genuinely matches what "interpreted" means in everyday use. The compiler exists, but it's invisible and automatic, which is exactly why the shorthand persists.

Does deleting __pycache__ ever make my program run differently, not just recompile? No — the bytecode in __pycache__ is always derived purely from your .py source. Deleting it forces a recompile from source, but it can't change behavior; if deleting it "fixes" something, the actual fix was getting a stale cache out of the way, not a change in logic.

Is bytecode the same across Python versions? No — bytecode format changes between major versions, which is why a .pyc file is named with its Python version (hello.cpython-312.pyc) and why one Python version's cache is never used by another.

Does this two-step process make Python slower than a compiled language? It's part of why, but not the whole story — the bigger factor is that bytecode still runs through an interpreter loop at runtime, rather than executing as native instructions the way the gcc-compiled example above does. The bytecode-compilation step itself is a small, one-time cost; the ongoing interpretation of that bytecode is where most of the actual runtime cost comes from.

Where this leaves you

Python is both compiled and interpreted — compiled to bytecode, then interpreted by a virtual machine — and knowing that explains several things people usually learn separately and never connect: why __pycache__ exists, why deleting it can fix a stale-cache bug, why Python can't ship as a single native binary the way a gcc-compiled program can, and why "Python is interpreted" was always a simplification of something more specific and more interesting.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous in language-featuresException handling in Python, done properly