Foundations · about 30 min

How Python runs your code

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:

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

Recap

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").

Exercises

Exercise 1 · quiz

Parse before run

A file contains:

print("one")
print("two")
print("three"

What happens when you run it with python3?

Answer
  • It prints nothing and reports a SyntaxError

CPython compiles the whole file to bytecode before executing any of it. The missing ) makes compilation fail, so not a single instruction runs. Compare a runtime error such as dividing by zero on line 3: then lines 1 and 2 would run and print first.

Exercise 2 · quiz

Errors at run time

This file is syntactically valid:

print("start")
print(10 / 0)
print("end")

Which lines of output appear before Python stops with ZeroDivisionError? Type them exactly as they would appear, separated by spaces if more than one, or type nothing.

Answer

Division by zero is a runtime error: the bytecode compiles fine, and the problem only shows up when the virtual machine actually executes the division. Everything before it has already run, so start is printed; end never is.

Exercise 3 · quiz

Order of the pipeline

Put CPython's steps in order. Which sequence is correct?

Answer
  • source text -> tokens -> syntax tree -> bytecode -> the virtual machine executes it

The tokenizer splits text into tokens (names, numbers, operators), the parser builds an abstract syntax tree (AST), the compiler turns the AST into bytecode instructions, and CPython's virtual machine, an evaluation loop written in C, executes those instructions one by one.

Exercise 4 · write code

Look at bytecode yourself

Write a program that uses the dis module to disassemble the statement total = price * quantity (pass it to dis.dis as a string, exactly as in the lesson).

The check looks for the instructions that load price and quantity, multiply them, and store the result in total, so the exact column layout does not matter. Run it first and read the output: can you follow the stack from top to bottom?

File: main.py

Starting point

import dis
# call dis.dis(...) here
Hint 1

dis.dis accepts a string of source code.

Hint 2

The whole program is two lines: import dis and dis.dis("total = price * quantity").

Reference solution
import dis
dis.dis("total = price * quantity")