How Python Actually Runs: Interpreter, Bytecode & the REPL¶
What this lesson gives you
What happens between pressing Enter and seeing output — a mental model that makes errors, performance, and the GIL stop being mysterious.
Estimated time: 22 min read · Part: Why Python, and How It Runs
Learning objectives
- Trace the path from source file to executed output through parsing, bytecode compilation, and the CPython VM.
- Distinguish between compile-time errors (SyntaxError) and runtime errors (NameError, TypeError) and explain why they happen at different moments.
- Use the REPL effectively for exploration, debugging, and thinking aloud in technical conversations.
- Understand what the GIL is and why it matters for threading (without having to solve it yet).
- Name CPython, PyPy, and Cython and explain what distinguishes each.
You don't need to know assembly, but a clear picture of how Python runs your code will pay off in every debugging session and every performance conversation. The model is simpler than it sounds: your source text is compiled to a portable intermediate representation, then a virtual machine runs that representation. Here is the honest, slightly simplified pipeline.
Parsing, the AST, and bytecode¶
When you run a .py file, the first thing CPython does is lex the source — scan it character by character and break it into tokens (keywords, names, operators, literals). Then it parses those tokens into an Abstract Syntax Tree (AST) — a tree structure that represents the grammatical meaning of your code. A function definition becomes a node with child nodes for its name, arguments, and body. This is where SyntaxError is raised: if the token stream doesn't match Python's grammar rules, parsing fails before a single line executes.
From the AST, the compiler generates bytecode — a compact, lower-level instruction set that isn't tied to any specific CPU architecture. Each Python statement typically compiles to several bytecode instructions (load a name, call a function, store a result). Bytecode is what gets cached in the __pycache__ directory as .pyc files: if the source hasn't changed since the last run, Python skips lexing/parsing/compiling and loads the cached bytecode directly. This is why second runs of the same script are slightly faster than first runs.
import dis # the disassembler module — peek at bytecode
def add(a, b):
return a + b
dis.dis(add) # print the bytecode instructions for add()
2 0 RESUME 0 3 2 LOAD_FAST 0 (a) 4 LOAD_FAST 1 (b) 6 BINARY_OP 0 (+) 10 RETURN_VALUE Each line is one bytecode instruction. LOAD_FAST 0 pushes the first local variable (a) onto the evaluation stack. LOAD_FAST 1 pushes b. BINARY_OP 0 (+) pops both, adds them, and pushes the result. RETURN_VALUE pops the top of the stack and returns it to the caller. The CPython VM is a stack machine: it maintains an evaluation stack and most instructions either push values onto it or pop and operate on them. You don't need to memorise this — but seeing it once makes "Python is interpreted" concrete rather than abstract.
Analogy
Think of a live interpreter at a conference versus a published translated book. A compiled language like C produces the finished book once (a machine-code executable) and every reader gets it instantly. Python is the live interpreter: it translates your instructions on the fly, every run. More flexible (you can change things mid-conversation), but there's translation overhead each time — the core reason it's slower than C. The .pyc cache is like having the interpreter's notes from last time, so they don't have to re-read the original every run.
The REPL: your fastest feedback loop¶
Type python (or uv run python) with no file and you enter the REPL — Read, Evaluate, Print, Loop. It reads one expression, evaluates it, prints the result, and waits for the next. It's the single best tool for "what does this actually do?" — in an interview, thinking out loud in a REPL is a strong signal that you understand how Python works interactively. It's also invaluable for quick data exploration mid-call.
Python 3.13 shipped a significantly improved interactive REPL with syntax highlighting, multi-line editing, and better error messages. It's accessible with just python. For even more powerful interactive work, IPython adds magic commands (%timeit, %pdb, %run), better tab completion, and is the shell Jupyter notebooks run under the hood.
>>> 2 + 2 # Read the expression, Evaluate it...
4 # ...Print the result, then Loop
>>> name = "Ada" # assignment produces no output
>>> f"Hi, {name}" # an f-string: builds text from the value
'Hi, Ada'
>>> _ # underscore holds the last result — useful shortcut
'Hi, Ada'
>>> import dis; dis.dis(lambda x: x * 2) # explore bytecode live
...
>>> type(42), type("hello"), type([1,2])
(<class 'int'>, <class 'str'>, <class 'list'>)
>>> help(str.split) # inline documentation for any object
Why the error timing matters
Two classes of error now make perfect sense: Compile-time (syntax errors, caught during AST construction, before any code runs) and runtime (NameError, TypeError, caught during bytecode execution). A SyntaxError: invalid syntax on line 50 means nothing on line 1–49 executed. A NameError: name 'x' is not defined on line 30 means everything before line 30 executed fine — the problem is specifically the moment that name lookup happens. This matters enormously for debugging: it tells you exactly when in the pipeline something went wrong.
CPython, PyPy, and the GIL¶
CPython is the reference implementation — almost everyone means CPython when they say "Python." It's written in C, maintained by the Python Software Foundation, and is the version you get from python.org or via uv. Its defining feature (and most controversial one) is the GIL: the Global Interpreter Lock, a mutex that allows only one Python thread to execute bytecode at a time, regardless of how many CPU cores you have. The GIL simplifies CPython's memory management but limits CPU parallelism in pure Python code.
Python 3.13 shipped an experimental free-threaded build (also called the "no-GIL" build), installable via uv python install 3.13t. This removes the GIL, allowing true parallel execution across CPU cores. It's experimental in 3.13 and expected to stabilise in 3.14–3.15. For now, the GIL still applies to the standard build — but it's worth knowing this is actively changing.
| Implementation | Language | Key characteristic | When to reach for it |
|---|---|---|---|
| CPython | C | Reference implementation, the standard. | 99% of all Python work. |
| PyPy | Python + RPython | JIT compiler; 3–10x faster for pure Python loops. | Long-running pure-Python computation (rarely). |
| Jython | Java | Runs on the JVM; integrates with Java libraries. | Legacy Java integration scenarios. |
| MicroPython | C | Minimal Python for microcontrollers. | Embedded / IoT devices. |
| Cython | C extension | Transpiles Python-like code to C; C-level speed. | Optimising a specific hot function. |
| CPython 3.13t | C (no-GIL) | Free-threaded; experimental. | Exploring future parallel Python (not production yet). |
import py_compile
import marshal
import pathlib
# Compile a .py file manually to a .pyc in __pycache__
py_compile.compile("main.py")
# List what's in __pycache__:
for p in pathlib.Path("__pycache__").iterdir():
print(p.name, p.stat().st_size, "bytes")
# main.cpython-313.pyc — the bytecode for Python 3.13
# The filename encodes the Python version so switching versions
# doesn't accidentally load stale bytecode.
main.cpython-313.pyc 412 bytes
PYTHON EXECUTION PIPELINE — TIMING OF ERRORS
═══════════════════════════════════════════════════════
Source text (.py)
│
▼
┌────────────────────────────────────────────────┐
│ PARSE PHASE (before any line runs) │
│ Tokens → AST → check grammar │
│ ✗ SyntaxError raised here if grammar breaks │
└────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────┐
│ COMPILE PHASE │
│ AST → bytecode (.pyc) │
│ ✗ Some SyntaxErrors caught here too │
└────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────┐
│ EXECUTION PHASE (CPython VM) │
│ Runs bytecode instruction by instruction │
│ ✗ NameError — name not defined at runtime │
│ ✗ TypeError — wrong types at runtime │
│ ✗ ValueError — bad value at runtime │
│ ✗ AttributeError — no such attribute │
└────────────────────────────────────────────────┘
Key insight: SyntaxError → nothing ran.
NameError on line 40 → lines 1–39 ran fine.
CPython vs the alternatives
CPython is the reference implementation almost everyone runs. You may hear of others: PyPy (a faster, JIT-compiling Python for long-running pure-Python work), Jython (runs on the Java VM), MicroPython (for microcontrollers), and tools like Cython (compiles Python-like code to C extensions for speed-critical functions). For 99% of work, "Python" means CPython — and CPython's GIL explains a lot of Python's threading behaviour, worth understanding even if the free-threaded build eventually supersedes it.
WHY bytecode instead of direct interpretation Direct interpretation — evaluating source text character by character — is slow and hard to optimise. Compiling to a compact bytecode first means the loop that runs code (the VM) operates on a much simpler, fixed-size instruction set. It can be tightly written in C and fast to dispatch. It also lets Python cache the bytecode so repeat runs skip the parsing overhead entirely.
HOW the GIL works in practice The GIL is a mutex (a lock) that any thread must hold to execute Python bytecode. One thread holds it, runs a few hundred bytecode instructions, releases it, and another thread can acquire it. This interleaving gives the illusion of parallelism for I/O-bound work (where threads spend most of their time waiting, not holding the GIL). But for CPU-bound parallel computation, only one thread runs at a time regardless of core count — the classic GIL limitation. The standard workaround is the multiprocessing module, which spawns separate processes, each with their own GIL.
WHERE this mental model pays off In performance conversations: you can explain why threading doesn't help CPU-bound Python but helps I/O-bound work, and why NumPy/PyTorch sidestep the GIL (they release it during C extension calls). In debugging: you immediately know whether a SyntaxError vs a NameError means the code ran at all. In interviews: accurately calling Python "compiled to bytecode, then interpreted" earns credibility no "it's just interpreted" answer does.
Knowledge check
Interview"Is Python compiled or interpreted?" — the classic warm-up. What's the precise, credibility-earning answer?
- Purely interpreted, line by line from source, with no compilation at all.
- Purely compiled to machine code like C.
- Both: source is first compiled to portable bytecode, then a virtual machine (CPython) interprets that bytecode — so it's "compiled to bytecode, then interpreted."
- It depends entirely on the operating system.
Answer
Both: source is first compiled to portable bytecode, then a virtual machine (CPython) interprets that bytecode — so it's "compiled to bytecode, then interpreted."
The accurate answer names both steps: a compile pass produces CPU-independent bytecode (cached as .pyc ), then the CPython virtual machine executes it. Saying "purely interpreted" is a common imprecision that a sharp interviewer will probe.
Knowledge check
Concept checkYour script has a valid function definition on line 5 and a NameError on line 20. Did the function definition execute? Why?
- No — a NameError on line 20 means nothing in the file ran.
- Yes — NameError is a runtime error raised during execution; lines 1–19 all ran before Python reached line 20 and tried to look up the undefined name.
- It depends on whether the function was called before line 20.
- The function definition would raise a SyntaxError, not a NameError.
Answer
Yes — NameError is a runtime error raised during execution; lines 1–19 all ran before Python reached line 20 and tried to look up the undefined name.
NameError is a runtime error — it fires when the VM executes the specific instruction that looks up a name that hasn't been defined. Everything before that instruction ran normally. If it were a SyntaxError on line 20, then nothing would have run (syntax is checked during compilation, before execution starts).
Knowledge check
AppliedYou have a Python web server handling 1,000 simultaneous HTTP requests. A colleague argues that Python's GIL means it can only handle one request at a time. Is this correct?
- Yes — the GIL means Python can never handle concurrent requests.
- No — HTTP requests are I/O-bound (waiting for network). While one thread waits for I/O it releases the GIL, allowing another to run. The GIL limits CPU parallelism, not I/O concurrency. Async frameworks (asyncio/uvicorn) avoid threading entirely, using a single thread with cooperative multitasking.
- Yes, which is why no production Python web framework exists.
- The GIL doesn't apply to web servers — only to scripts.
Answer
No — HTTP requests are I/O-bound (waiting for network). While one thread waits for I/O it releases the GIL, allowing another to run. The GIL limits CPU parallelism, not I/O concurrency. Async frameworks (asyncio/uvicorn) avoid threading entirely, using a single thread with cooperative multitasking.
The GIL allows other threads to run while a thread is waiting for I/O (network, disk), because waiting threads release the GIL. This is why Python web servers work fine with threading for I/O-bound request handling. The GIL only becomes a bottleneck when you want true CPU parallelism across threads — for example, doing heavy computation in parallel. The async model (asyncio, used by FastAPI/uvicorn) sidesteps the GIL entirely by using a single thread and cooperative coroutines.
Exercise
Use the dis module to disassemble two versions of the same logic: one using a list comprehension and one using a for loop with .append(). Compare the bytecode outputs. Which has fewer instructions? Then use timeit to measure which is faster for squaring a list of 1,000 numbers. Write a two-sentence explanation of why the comprehension is typically faster.
Show solution
import dis
import timeit
def via_loop(nums):
result = []
for n in nums:
result.append(n * n)
return result
def via_comprehension(nums):
return [n * n for n in nums]
print("=== LOOP ===")
dis.dis(via_loop)
print("\n=== COMPREHENSION ===")
dis.dis(via_comprehension)
nums = list(range(1000))
loop_time = timeit.timeit(lambda: via_loop(nums), number=10000)
comp_time = timeit.timeit(lambda: via_comprehension(nums), number=10000)
print(f"\nLoop: {loop_time:.3f}s")
print(f"Comprehension: {comp_time:.3f}s")
# Explanation:
# The list comprehension is faster because it avoids the overhead of
# repeatedly looking up the 'append' method on the result list inside the
# loop. Internally, the comprehension uses a dedicated LIST_APPEND bytecode
# that the VM can dispatch more efficiently than a full attribute lookup +
# function call cycle (LOAD_ATTR 'append' + CALL) on every iteration.
Key takeaways
- Python compiles to bytecode, then the CPython virtual machine interprets it — "both," not purely interpreted.
- Bytecode is portable; cached
.pycfiles in__pycache__speed up re-runs. - SyntaxError fires before execution (compile phase); NameError/TypeError fire during execution (runtime).
- The REPL (Read-Eval-Print-Loop) is your fastest experimentation tool — use it to think out loud.
- The GIL limits CPU parallelism in threads but doesn't prevent I/O concurrency — important for web and data engineering contexts.
- CPython 3.13's experimental free-threaded build signals the GIL's days are numbered, but the standard build still has it.