Skip to main content

Compilation vs Interpretation - How Python Actually Runs Your Code

Reading time: ~18 minutes | Level: Foundation → Engineering

Here is a question most Python developers cannot answer correctly:

$ python app.py

What happens between pressing Enter and your first print() statement executing?

If your answer is "Python reads the file and executes it line by line" - you have the wrong model. That model will hurt you when you try to optimize Python, when you debug mysterious __pycache__ directories, when you try to understand why PyPy is 10x faster for some workloads, or when a senior engineer asks you in an interview to explain Python's execution model.

The real answer is more interesting, more nuanced, and more useful.

What You Will Learn

  • Why "Python is an interpreted language" is an incomplete and misleading statement
  • The exact pipeline from .py file to CPU instructions
  • What bytecode is, where it lives, and how to inspect it yourself
  • How the Python Virtual Machine (PVM) executes bytecode
  • What CPython is, and why it matters that it is the reference implementation
  • Why JIT compilation exists and what PyPy does differently
  • How dynamic typing forces runtime overhead that compiled languages avoid
  • How Python's execution model affects real architectural decisions
  • The connection between this and NumPy's speed advantage over pure Python

Prerequisites

  • Python installed and runnable (python --version works)
  • Comfortable with the concept that there are multiple programming languages
  • No performance tuning experience required

The Mental Model: Three Tiers of Language Execution

Before diving into Python specifically, you need the map of the territory.

Three-tier language execution models: Fully Compiled (C/Rust/Go), Fully Interpreted (BASIC), and Bytecode+VM (Python/Java)

Python occupies Tier 3. It compiles, then interprets. The compilation step is fast and largely invisible. The interpretation step is where your code actually runs.

Part 1 - The Classical Compilation Model (C, C++, Rust)

To understand Python's approach, you need to understand what it is not.

In a fully compiled language like C, the pipeline looks like this:

C compilation pipeline: hello.c source → gcc compiler → binary machine code → CPU executes natively

The output binary contains actual CPU instructions - opcodes that the processor understands natively. No translation happens at runtime. The compiler has already done all the work.

What this gives you:

  • Maximum execution speed - code runs at CPU speed
  • Aggressive compiler optimization (inlining, dead code elimination, vectorization)
  • Platform-specific binary (must recompile for each target architecture)

What you give up:

  • Compilation time (can be minutes for large projects)
  • Type errors discovered at compile time only (good for safety, harder for rapid iteration)
  • Tighter memory management responsibility
// C: type is known at compile time
int x = 10; // 4 bytes, exactly
int y = x + 5; // CPU does this in one instruction

The C compiler knows at compile time that x is a 32-bit integer. It generates a single assembly instruction for the addition. No type checking at runtime. No overhead.

Part 2 - What Python Actually Does

When you run python app.py, Python performs two distinct phases:

Phase 1: Compilation to Bytecode

Python reads your source file, parses it, checks for syntax errors, and compiles it to bytecode - a compact, lower-level representation that is not machine code, but is easier and faster for Python's virtual machine to execute than raw Python source.

Phase 2: Bytecode Execution in the PVM

The Python Virtual Machine (PVM) reads the bytecode instructions one by one and executes them. This is the "interpretation" step.

Python two-phase execution: source code → Phase 1 CPython Compiler (Lexing→Parsing→CodeGen) → .pyc bytecode → Phase 2 PVM

:::note The Key Insight Python does compile. It compiles to bytecode, not machine code. The bytecode is then interpreted by the PVM. This is a hybrid model - not purely compiled, not purely interpreted. :::

Watch: Python's Execution Model Explained

Part 3 - What Is Bytecode?

Bytecode is a compact, intermediate representation of your Python program. It is:

  • Lower-level than Python source - closer to machine instructions
  • Higher-level than machine code - still needs a virtual machine to run
  • Platform-independent - the same .pyc file runs on any OS that has a compatible Python version
  • Not human-readable directly, but inspectable with Python's dis module

You can inspect bytecode yourself:

import dis

def add_numbers(a, b):
result = a + b
return result

dis.dis(add_numbers)

Output:

2 0 RESUME 0

3 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 STORE_FAST 2 (result)

4 12 LOAD_FAST 2 (result)
14 RETURN_VALUE

Each line is one bytecode instruction. Reading from top to bottom:

  1. LOAD_FAST 0 (a) - Push the value of local variable a onto the stack
  2. LOAD_FAST 1 (b) - Push the value of local variable b onto the stack
  3. BINARY_OP 0 (+) - Pop top two values, add them, push result
  4. STORE_FAST 2 (result) - Pop the stack top and store it as result
  5. LOAD_FAST 2 (result) - Push result onto the stack
  6. RETURN_VALUE - Return the stack top to the caller

This is a stack-based virtual machine. All operations happen by pushing and popping values on an internal stack.

:::tip Inspecting Bytecode in Practice The dis module is genuinely useful for performance debugging. When two Python implementations produce different bytecode, the one with fewer instructions is generally faster. :::

Let's look at something more interesting:

import dis

def conditional_example(x):
if x > 0:
return "positive"
else:
return "non-positive"

dis.dis(conditional_example)

Output:

2 0 RESUME 0

3 2 LOAD_FAST 0 (x)
4 LOAD_CONST 1 (0)
6 COMPARE_OP 4 (>)
12 POP_JUMP_IF_FALSE 10 (to 34)

4 14 LOAD_CONST 2 ('positive')
16 RETURN_VALUE

6 >> 18 LOAD_CONST 3 ('non-positive')
20 RETURN_VALUE

You can see the COMPARE_OP (the > comparison) and the POP_JUMP_IF_FALSE (the if branch). The bytecode makes the control flow explicit.

Part 4 - The Python Virtual Machine (PVM)

The PVM is the runtime engine that executes bytecode. In CPython (the reference implementation), the PVM is implemented in C in a file called ceval.c - a massive C function that is essentially a loop running through bytecode instructions.

CPython PVM Architecture - Value Stack, Bytecode Instructions, Frame Stack, GIL, and Memory Allocator

Key components:

  • Value stack - Where intermediate computation results live. Every expression evaluation pushes and pops this stack.
  • Frame stack - Every function call creates a new frame. Frames contain local variables, the value stack, and a reference to the code object (bytecode).
  • Global Interpreter Lock (GIL) - Ensures only one thread executes Python bytecode at a time. This is why CPython threads cannot truly parallelize CPU-bound work.

Part 5 - The __pycache__ Directory and .pyc Files

When Python compiles your source file, it caches the bytecode:

myproject/
├── app.py
├── utils.py
└── __pycache__/
├── app.cpython-312.pyc
└── utils.cpython-312.pyc

The .pyc files contain:

  • A magic number (identifies the Python version)
  • A timestamp or hash of the source file
  • The compiled bytecode

Next time you run app.py, Python checks if the .pyc is fresh (source file unchanged, same Python version). If yes, it skips compilation and goes straight to execution. This is a performance optimization - parsing and compiling Python source takes time.

# You can force Python to compile without running:
import py_compile
py_compile.compile("app.py")

# Or compile to optimize (strips docstrings):
# python -O app.py (creates .opt-1.pyc)
# python -OO app.py (creates .opt-2.pyc, also strips assert statements)

:::warning Bytecode is Not Security Distributing .pyc files does not protect your source code. Bytecode is trivially decompiled back to Python-like source. If you need to protect code, .pyc is not the answer. :::

Watch: Python Compilation and Bytecode Deep Dive

Part 6 - Why Python is Slower Than C (And By How Much)

Consider adding two numbers:

# Python
x = 10
y = 20
result = x + y

Here is what the PVM actually does for x + y:

  1. Look up x in the local namespace dictionary - hash lookup
  2. Look up y in the local namespace dictionary - hash lookup
  3. Check the type of x - what is type(x)?
  4. Check the type of y - what is type(y)?
  5. Look up the __add__ method on the type of x
  6. Call __add__(x, y) through the method dispatch mechanism
  7. The actual integer addition happens inside int.__add__
  8. A new integer object is allocated on the heap
  9. The reference count is set, type pointer is set
  10. The result is returned and pushed onto the stack

Compare with C:

int x = 10;
int y = 20;
int result = x + y;

The C compiler generates approximately:

mov eax, 10
add eax, 20
mov [result], eax

Three CPU instructions. Python takes hundreds.

This is not a Python bug. It is the price of dynamic typing, garbage collection, and the object model. Python trades raw arithmetic speed for:

  • Variables that can hold any type at any time
  • Automatic memory management
  • Runtime introspection and modification of running code
  • Clean, readable syntax
# You can measure this yourself
import timeit

# Python loop: O(n) additions in Python objects
python_time = timeit.timeit(
"total = sum(range(1_000_000))",
number=10
)

print(f"Python: {python_time:.3f}s")
# NumPy: same operation in C
import numpy as np
import timeit

numpy_time = timeit.timeit(
"np.arange(1_000_000).sum()",
setup="import numpy as np",
number=10
)

print(f"NumPy: {numpy_time:.3f}s")

NumPy will be 10-100x faster because it bypasses the Python object model for the actual computation.

Part 7 - Dynamic Typing and Runtime Dispatch

Python determines types at runtime. This is the core cost.

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

add(1, 2) # Works: int + int
add("hello", "!") # Works: str + str
add([1, 2], [3]) # Works: list + list
add(1, "hello") # TypeError at RUNTIME

The same function works with completely different types. The PVM resolves + by looking up __add__ on whatever type a happens to be at runtime.

In C, this function would need to be compiled separately for each type (or use templates/generics). In Python, one function handles all types dynamically - at the cost of runtime type lookup on every call.

This dynamic dispatch is visible in the bytecode - BINARY_OP does not know at compile time what types it will be adding. It finds out at runtime on every call.

Part 8 - JIT Compilation: The PyPy Alternative

CPython does not use JIT compilation. PyPy does.

Just-In-Time (JIT) compilation means: instead of interpreting bytecode repeatedly, a JIT compiler watches which bytecode is executed frequently ("hot paths") and compiles those paths to native machine code at runtime.

CPython (no JIT):
Bytecode → PVM interprets → [repeat for every execution]

PyPy (with JIT):
Bytecode → PVM interprets → [detects hot path]
→ JIT compiles hot path to machine code
→ [subsequent calls run machine code directly]

For computation-heavy loops (like number crunching, text processing, simulations), PyPy can be 5-50x faster than CPython.

# This kind of code benefits enormously from PyPy's JIT
def compute_heavy(n):
total = 0
for i in range(n):
total += i * i
return total

# In CPython: every iteration goes through PVM
# In PyPy: after warmup, JIT compiles the loop to native machine code
result = compute_heavy(10_000_000)

Why doesn't CPython use JIT?

  • Complexity: JIT compilers are extremely complex
  • Memory overhead: JIT-compiled code takes memory
  • Warmup time: JIT needs time to identify and compile hot paths
  • The CPython team prioritizes stability and correctness

Python 3.13+ introduced an experimental JIT optimizer. The landscape is changing.

Part 9 - CPython vs Other Python Implementations

"Python" the language and "CPython" the implementation are different things.

ImplementationDescriptionBest for
CPythonReference implementation in CStandard Python, ecosystem compatibility
PyPyJIT-compiled Python in RPythonCPU-bound pure Python code
JythonPython on the JVMJava ecosystem integration
IronPythonPython on .NET CLR.NET ecosystem integration
MicroPythonPython for microcontrollersEmbedded systems, IoT
GraalPyPython on GraalVMPolyglot systems

When someone says "Python," they almost always mean CPython. The behaviors discussed on this page - bytecode, PVM, GIL, .pyc files - are CPython specifics. Other implementations may handle them differently.

AI/ML Real-World Connection

The execution model directly impacts machine learning performance.

Why NumPy is fast:

import numpy as np

# Python loop: every iteration goes through PVM
def python_dot_product(a, b):
total = 0
for x, y in zip(a, b):
total += x * y
return total

# NumPy: computation runs entirely in C, no PVM per element
def numpy_dot_product(a, b):
return np.dot(a, b)

# For 1,000,000-element arrays, NumPy is ~200x faster

NumPy arrays store data as contiguous C arrays (not Python objects). Operations like np.dot() call into C/Fortran routines that run without the PVM. Python is only involved in the function call - not in the actual computation.

PyTorch's approach:

import torch

# Tensor operations are compiled C++/CUDA - not Python bytecode
x = torch.randn(1000, 1000)
y = torch.randn(1000, 1000)
result = torch.matmul(x, y) # Runs in C++/CUDA, not PVM

PyTorch's Python API is a thin wrapper around a C++ computation engine. The Python execution model is bypassed for actual tensor math. Python acts as the "glue language" that orchestrates C++ operations.

This is the architectural pattern that makes Python viable for high-performance ML: write the orchestration in Python (clean, expressive), run the computation in C/C++/CUDA (fast).

:::info Python as Orchestration Language The most important thing the execution model teaches you: Python is often the coordinator of performance, not the performer. Understanding bytecode helps you identify where Python is doing work vs where C is doing work. :::

Common Mistakes and Misconceptions

Mistake 1: Thinking Python compiles nothing

# Misconception: "Python reads line by line"
# Reality: Python compiles the entire function before executing it

def broken():
x = 1
return x
y = undefined_variable # SyntaxError? No - NameError at runtime...
# actually this is unreachable code with no error

Python's compilation step catches syntax errors before execution. But semantic errors (like undefined variable names) are caught at runtime. The compilation step is real - it just produces bytecode, not machine code.

Mistake 2: Assuming .pyc files provide security or optimization

# .pyc files are NOT:
# - Obfuscated source (trivially decompilable)
# - Machine code (still interpreted by PVM)
# - Significant optimization (same bytecode runs)

# They ARE:
# - Cached compilation (saves parse time on re-runs)
# - Version-stamped (invalid across Python versions)

Mistake 3: Thinking the GIL makes Python single-core forever

import threading
import numpy as np

# This parallelizes correctly - NumPy releases the GIL
def compute_in_thread():
data = np.random.randn(1_000_000)
return np.sum(data ** 2)

# NumPy operations run in C and release the GIL
# Multiple threads can run NumPy simultaneously
threads = [threading.Thread(target=compute_in_thread) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()

The GIL prevents multiple Python threads from executing Python bytecode simultaneously. But C extensions like NumPy can release the GIL during computation, enabling true parallelism.

Interview Questions

Q1: Is Python compiled or interpreted?

Answer: Python is neither purely compiled nor purely interpreted - it uses a hybrid model. When you run a Python file, CPython first compiles the source code to bytecode (.pyc files in __pycache__). This bytecode is then executed by the Python Virtual Machine (PVM), which is an interpreter. So Python compiles to bytecode, then interprets that bytecode. The common claim that "Python is interpreted" is an oversimplification.

Q2: What is bytecode and why does Python use it?

Answer: Bytecode is a compact, platform-independent intermediate representation between Python source code and machine code. It is lower-level than Python source (easier/faster to execute) but higher-level than native machine code (requires the PVM to run). Python uses bytecode because it allows the compilation step to catch syntax errors early, enables caching (.pyc files avoid recompiling unchanged files), and maintains portability across operating systems without recompiling.

Q3: What is the GIL and why does it exist?

Answer: The Global Interpreter Lock (GIL) is a mutex in CPython that ensures only one thread executes Python bytecode at a time. It exists because CPython's memory management (particularly reference counting) is not thread-safe, and the GIL was the simplest solution to prevent race conditions. Consequence: CPU-bound Python threads cannot truly parallelize. Workaround: use multiprocessing (separate processes, no shared GIL) or C extensions that release the GIL (like NumPy). Python 3.13 introduced an experimental no-GIL mode.

Q4: Why is CPython slower than PyPy for computation-heavy code?

Answer: CPython interprets bytecode on every execution - there is no optimization based on runtime behavior. PyPy includes a JIT (Just-In-Time) compiler that identifies frequently-executed code paths ("hot paths") and compiles them to native machine code at runtime. After warmup, hot paths in PyPy execute as native machine code rather than being interpreted, giving 5-50x speedups for CPU-bound pure Python.

Q5: What is a stack-based virtual machine?

Answer: In a stack-based VM like Python's PVM, all operations work by pushing values onto an internal value stack and popping them off. For example, a + b is implemented as: push a, push b, execute BINARY_OP (pops two values, adds them, pushes result). This contrasts with register-based VMs (like Lua's or Dalvik's) where values are stored in named "registers" rather than a stack. Stack-based VMs are simpler to implement; register-based VMs can be faster because they require fewer stack manipulation instructions.

Q6: How does dis.dis() help with performance optimization?

Answer: dis.dis() shows the bytecode generated for a Python function, revealing exactly what the PVM will execute. You can compare two implementations and pick the one with fewer or cheaper bytecode instructions. For example, comparing a list comprehension vs a for loop using dis reveals that comprehensions have a dedicated LIST_APPEND bytecode instruction that is more efficient than the loop equivalent. Real-world use: identifying unnecessary attribute lookups (slow), unnecessary dict creations, or redundant function calls in hot paths.

Quick Reference Cheatsheet

ConceptWhat it isKey Tool
Source codeHuman-readable .py fileText editor
BytecodeCompiled intermediate formdis.dis(), __pycache__/
PVMThe bytecode interpreterpython command
CPythonReference Python implementationpython --version
PyPyJIT-compiled Pythonpypy command
GILPer-process thread lockthreading docs
.pycCached bytecode file__pycache__/ directory
__pycache__Directory of cached bytecodeAuto-created by Python
Stack-basedPVM uses push/pop for operationsdis module output
JITRuntime compilation of hot pathsPyPy-specific

Graded Practice Challenges

Level 1 - Predict the Output

What does this print?

import dis

def mystery(x):
return x * 2

dis.dis(mystery)
Show Answer

The output will show bytecode instructions for the function body. Something like:

2 0 RESUME 0

3 2 LOAD_FAST 0 (x)
4 LOAD_CONST 1 (2)
6 BINARY_OP 5 (*)
10 RETURN_VALUE

The exact format depends on Python version. Key instructions: LOAD_FAST loads x (local variable), LOAD_CONST loads the literal 2, BINARY_OP with multiplication, RETURN_VALUE returns the result.

Level 1 - True or False

True or false: .pyc files make Python code faster because the CPU can execute bytecode directly.

Show Answer

False.

.pyc files save compilation time (parsing source to bytecode) on repeated runs. The CPU cannot execute bytecode directly - bytecode still requires the PVM to interpret it. .pyc files do not change how fast your code runs, only how fast Python starts executing it.

Level 2 - Debug the Misconception

A colleague says: "I'm going to distribute only the .pyc files of my app to protect my source code from theft."

What is wrong with this plan?

Show Answer

Two problems:

  1. Bytecode is trivially decompilable. Tools like uncompyle6 and decompile3 can reconstruct Python source code from .pyc files with high accuracy. Distributing .pyc files provides essentially zero source code protection.

  2. Bytecode is version-dependent. .pyc files compiled for Python 3.11 will not run on Python 3.12 - the magic number in the file header encodes the Python version. Users would need the exact same Python version you used to compile.

Better approaches for code protection: encryption + runtime decryption (complex), Cython (compiles to C), licensing agreements, or designing your system so the most valuable logic runs server-side.

Level 3 - Design Challenge

You are building a Python service that needs to process 10 million integers per second (a real-time data processing pipeline). You benchmark pure Python and it achieves 500,000 integers per second - 20x too slow.

Without changing the algorithm, explain three different approaches to close that performance gap, and for each approach, explain which part of the execution model you are exploiting.

Show Answer

Approach 1: Use NumPy for vectorized operations

import numpy as np

# Instead of:
total = sum(x * x for x in data) # PVM executes per element

# Use:
arr = np.array(data)
total = np.sum(arr ** 2) # C executes per element, GIL released

Exploitation: NumPy's C implementation bypasses the PVM for the actual computation. Python only orchestrates the function call. The GIL is released during NumPy's C execution, allowing the computation to use CPU caches efficiently.

Approach 2: Use Cython or Numba for JIT/AOT compilation

# Numba JIT
from numba import njit

@njit
def process(data):
total = 0
for x in data:
total += x * x
return total

# First call: JIT compiles to machine code
# Subsequent calls: runs at C speed

Exploitation: Numba's JIT compiler compiles the Python function to native machine code (similar to PyPy's JIT, but applied selectively). The PVM is completely bypassed for the decorated function.

Approach 3: Use multiprocessing to parallelize

from multiprocessing import Pool
import numpy as np

def process_chunk(chunk):
arr = np.array(chunk)
return np.sum(arr ** 2)

with Pool(processes=8) as pool: # 8 CPU cores
chunks = [data[i::8] for i in range(8)]
results = pool.map(process_chunk, chunks)
total = sum(results)

Exploitation: Each process has its own Python interpreter and GIL. With 8 cores, you can theoretically process 8x as many integers per second. Combined with NumPy in each process, this compounds the gains.

The key insight: you are not fighting the PVM - you are bypassing it for computation while using Python only for coordination.

Key Takeaways

  • Python uses a hybrid model: compile to bytecode, then interpret bytecode in the PVM
  • "Python is interpreted" is a simplification - it also compiles (to bytecode, not machine code)
  • Bytecode is platform-independent but requires the PVM to execute
  • CPython is the reference implementation; PyPy, Jython, and others use different execution strategies
  • The GIL limits CPU parallelism in threads but does not affect multiprocessing or C extension parallelism
  • Python's speed limitations come from dynamic typing (runtime type resolution), object overhead (everything is an object), and per-instruction PVM overhead
  • NumPy, PyTorch, and pandas are fast because they run their computations in C/C++/CUDA, bypassing the PVM
  • PyPy's JIT compiles hot paths to native machine code at runtime - 5-50x faster for CPU-bound pure Python
  • Understanding the execution model is how you know where to optimize and why a particular approach is faster
© 2026 EngineersOfAI. All rights reserved.