How Python Runs Your Code
Source code is compiled to bytecode and the Python Virtual Machine executes it. Knowing that explains __pycache__, import speed and most confusing error messages.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
Python is not only interpreted
Python is usually described as an interpreted language, and in day to day use that is a fair description: you hand it a source file and it starts running. Underneath, however, there is a compilation step. Python compiles your source into an intermediate form called bytecode, and a program called the Python Virtual Machine executes that bytecode instruction by instruction.
The pipeline
program.py your source code
| tokeniser text -> tokens
v
tokens
| parser tokens -> abstract syntax tree
v
AST
| compiler AST -> bytecode
v
program bytecode
| Python Virtual Machine
v
the program runsEach stage can fail, and which stage failed tells you what kind of mistake you made.
| Stage | What it checks | Typical failure |
|---|---|---|
| Tokeniser | Characters form legal tokens, indentation is consistent | IndentationError, TabError |
| Parser | Tokens form legal statements | SyntaxError |
| Compiler | Statements can be turned into instructions | SyntaxError for things such as return outside a function |
| Virtual machine | Nothing in advance; it simply runs | Every other error, at the moment the line executes |
This is the key insight. ASyntaxErrorstops the whole file before a single line runs. ANameErrororTypeErrorappears only when execution reaches the offending line, which is why a program can run happily for an hour and then fail on a branch nobody had exercised.
Seeing the bytecode
The standard library module dis disassembles a function so you can read the instructions the compiler produced.
import dis
def total(a, b):
return a + b * 2
dis.dis(total)LOAD_FAST a
LOAD_FAST b
LOAD_CONST 2
BINARY_OP *
BINARY_OP +
RETURN_VALUEThe exact output varies between Python versions, and it is not something to memorise. It is worth looking at once, because it makes two things concrete: the compiler already decided that b * 2 happens before the addition, and the machine executing your code works on a simple stack of values.
__pycache__ and .pyc files
Compiling takes time. When you import a module, Python caches the compiled bytecode in a folder named __pycache__, in a file ending in .pyc. Next time, if the source has not changed, the cached bytecode is loaded and the compile step is skipped.
project/
main.py
helpers.py
__pycache__/
helpers.cpython-312.pyc- Only imported modules are cached. The script you run directly is not, because it is compiled once and then the process ends.
- The cache is keyed by the source timestamp and size, so an edit invalidates it automatically.
- The file name records the interpreter version, so two Python versions never read each other's cache.
__pycache__is generated output. It belongs in.gitignore, never in version control.
Deleting __pycache__ is always safe. Python simply rebuilds it.
Implementations
The Python you almost certainly installed is CPython, the reference implementation written in C. The language is a specification, and other implementations exist that follow it.
| Implementation | Written in | Notable for |
|---|---|---|
| CPython | C | The reference. Everything in these notes targets it. |
| PyPy | Python | A just in time compiler; long running numeric loops can be far faster. |
| Jython | Java | Runs on the Java Virtual Machine. |
| MicroPython | C | A trimmed Python for microcontrollers. |
Names such as the global interpreter lock are properties of CPython, not of the Python language. That distinction matters in the concurrency notes later in this path.
Why any of this matters in practice
- You can explain why a typo on the last line of a file prevents the first line from running.
- You know why the second import of a large module is faster than the first.
- You will not commit
__pycache__, and you will not be alarmed by it. - You understand that "compiled" and "interpreted" are stages in one pipeline, not opposing categories.
Common mistakes
- Editing a
.pycfile. It is generated; edit the.pysource. - Shipping
__pycache__or committing it, which causes noise in every diff. - Believing a program is correct because it started. Only the syntax was verified in advance.
- Assuming bytecode is machine code. It is instructions for the Python virtual machine, not for your processor.
Practice
- Write a file whose last line is
prnt("done")and whose first line isprint("start"). Predict the output before running it, then explain the result. - Disassemble a function containing
a + b * 2and a second containing(a + b) * 2. Describe the difference in the instruction order. - Import a module twice in one session and explain why the second import does almost nothing.
- Explain in two sentences why a
SyntaxErrorand aZeroDivisionErrorare detected at different times.
Conclusion
Source becomes bytecode, and the virtual machine runs the bytecode. That one sentence accounts for __pycache__, for the difference between errors found before running and errors found while running, and for the fact that Python is both compiled and interpreted depending on which stage you are describing.