Why this matters
When something goes wrong, you need to know when it went wrong: before anything ran, or halfway through. Knowing that Python first compiles your whole file to bytecode, then executes it, explains why a typo on line 300 stops line 1 from running, why some errors appear only for certain inputs, and why .pyc files and __pycache__ folders show up in your projects. It also demystifies the word "interpreter".
The pipeline inside python3
The last lesson drew python3 as a box that "reads your source and does what it says". Inside that box, CPython works in two phases: compile and then execute.
hello.py (text)
|
| 1. tokenize: split text into tokens
v
NAME 'print' OP '(' STRING '"hi"' OP ')'
|
| 2. parse: build an abstract syntax tree (AST)
v
Call(func=Name('print'), args=[Constant('hi')])
|
| 3. compile: turn the tree into bytecode
v
LOAD_NAME print / LOAD_CONST 'hi' / CALL 1 / ...
|
| 4. execute: the virtual machine runs the bytecode
v
"hi" appears on your terminal
Steps 1-3 happen for the whole file before step 4 starts.
Tokenizing breaks the text into meaningful chunks: names like print, literals like "hi" and 42, operators like + and (. Whitespace at the start of a line is significant in Python, and the tokenizer turns it into INDENT and DEDENT tokens, which is how Python knows which lines belong to a block.
Parsing checks that the tokens fit Python's grammar and builds an abstract syntax tree: a data structure describing the program's structure ("a call of print with one argument"). If the tokens do not fit the grammar, you get a SyntaxError here.
Compiling walks the tree and emits bytecode: a compact list of simple instructions for an imaginary machine. These are not CPU instructions; they are designed for Python.
Executing is done by CPython's virtual machine (VM), also called the evaluation loop. It is a big loop written in C that fetches the next bytecode instruction, does what it says, and moves on. CPython's VM is a stack machine: instructions push values onto a stack and pop them off to work on them.
Seeing bytecode
The dis module (short for disassembler) shows the bytecode for any piece of code. Try this program:
import dis
dis.dis("x = a + b * c")
On Python 3.13 the output looks like this (column details vary slightly between versions):
0 RESUME 0
1 LOAD_NAME 0 (a)
LOAD_NAME 1 (b)
LOAD_NAME 2 (c)
BINARY_OP 5 (*)
BINARY_OP 0 (+)
STORE_NAME 3 (x)
RETURN_CONST 0 (None)
Read it like a stack machine:
LOAD_NAME a stack: [a]
LOAD_NAME b stack: [a, b]
LOAD_NAME c stack: [a, b, c]
BINARY_OP * pops b and c, pushes b*c stack: [a, b*c]
BINARY_OP + pops a and b*c, pushes sum stack: [a + b*c]
STORE_NAME x pops the result, binds the name x to it
You do not need to memorize any of this. The point is that the compiler has already worked out that multiplication comes first (operator precedence) and turned your expression into a fixed sequence of steps. At run time the VM just follows them.
import dis is your first import: it loads a module from Python's standard library so its functions can be used. A whole module later covers imports properly.
Two kinds of errors
Because compiling and executing are separate phases, errors come in two families.
Syntax errors happen during compilation. The file cannot be turned into bytecode at all, so nothing runs:
print("one")
print("two"
File "demo.py", line 2
print("two"
^
SyntaxError: '(' was never closed
Even one is not printed, although line 1 is perfectly fine.
Runtime errors (Python calls them exceptions) happen during execution, when the VM tries to do something impossible:
print("one")
print(1 / 0)
print("three")
one
Traceback (most recent call last):
File "demo.py", line 2, in <module>
print(1 / 0)
~~^~~
ZeroDivisionError: division by zero
Line 1 ran and printed one. Line 2 raised ZeroDivisionError, and because nothing handled it, the program stopped; line 3 never ran. The traceback shows where execution was when it failed. Many runtime errors depend on data: a division is fine until the divisor happens to be zero. That is why programs must be tested with many inputs, a theme of the testing module.
Bytecode caching: pycache and .pyc
Compiling takes time, so when you import a module, CPython saves its bytecode to a file such as __pycache__/mymodule.cpython-313.pyc. Next time, if the source has not changed, Python loads the cached bytecode and skips steps 1-3. The script you run directly (python3 main.py) is compiled fresh each time and not cached; only imported modules are.
So .pyc files are not "compiled Python" in the C sense. They hold bytecode for the VM, tied to a Python version, and you can safely delete them; Python will regenerate them. Most projects list __pycache__/ in .gitignore.
"Interpreted" versus "compiled", honestly
People call Python "interpreted" and C "compiled". The more precise story:
- C is compiled ahead of time to machine code for a specific CPU. The CPU runs it directly.
- CPython compiles to bytecode for a virtual machine, every time (or from the cache). A program (the VM) interprets that bytecode.
Running bytecode through an interpreter loop is slower than running native machine code, often 10-100 times slower for tight numeric loops. In exchange you get portability (the same .py runs anywhere CPython runs), a fast edit-run cycle, and powerful run-time features you will meet later, such as creating functions and classes while the program runs. Other implementations exist too: PyPy adds a just-in-time compiler that turns hot bytecode into machine code, and CPython 3.13 ships an experimental JIT of its own. In this track "Python" means CPython.
Common mistakes
- Assuming earlier lines ran. If you see
SyntaxError, nothing ran. Fix the syntax first; there is no partial output to reason about. - Looking at the wrong line. For an unclosed bracket or quote, Python often reports the problem on a later line, where it finally noticed the structure was broken. If the reported line looks fine, look at the lines just above it.
- Committing
__pycache__. It is generated output. Ignore it in version control.
Recap
- CPython tokenizes, parses into an AST, and compiles your whole file to bytecode, then its virtual machine executes the bytecode.
SyntaxErrorhappens at compile time: nothing runs. Exceptions likeZeroDivisionErrorhappen at run time: earlier lines have already run.- Bytecode is a list of simple stack-machine instructions;
dis.dis(...)shows it. - Imported modules get their bytecode cached in
__pycache__/*.pyc.
Going further: try import ast; print(ast.dump(ast.parse("x = a + b * c"), indent=2)) to see the syntax tree, and read the dis module documentation (docs.python.org, "dis - Disassembler for Python bytecode").