01_Python Under the Hood
Understanding Python: Compilation, the PVM, and the Role of __pycache__ in Your Folders

When I first started writing Python, I often heard it described simply as an "interpreted language." I imagined the computer reading my code line-by-line and executing it on the fly.
While that’s not entirely wrong, it is a massive oversimplification that misses the fascinating engineering happening beneath the surface.
The reality of standard Python (CPython) is a hybrid approach. It doesn't just "read" your code; it compiles it into an intermediate language called Bytecode, and then a virtual machine (PVM) interprets that bytecode.
In this deep dive, let's trace the lifecycle of a Python script—from the text we write to the actual CPU operations.
The High-Level Workflow
Before we get lost in the weeds, let's look at the big picture. When you type python main.py in your terminal, three distinct stages kick off:
Compilation: The source code is translated into Bytecode.
Execution: The Python Virtual Machine (PVM) loads and interprets that bytecode.
Memory Management: Throughout the process, Python automatically handles memory allocation and cleanup so we don't have to.
Let's break these down.
Stage 1: The Compilation Process
Unlike C++ or Rust, which compile directly to machine code (binary instructions specific to your CPU architecture), Python compiles to bytecode. This step happens automatically and is usually hidden from us.
The compiler performs a few transformations to turn human-readable code into something the virtual machine understands:
1. Syntax Checking and Tokenization
First, the interpreter checks for syntax errors. If the grammar holds up, the Lexer breaks the source code text into individual atoms called tokens.
- Example: The line
if variable == 10:isn't read as a sentence. It's broken into tokens like:NAME(if),NAME(variable),OP(==),NUMBER(10), andOP(:).
2. Parsing & The AST
Next, the parser analyzes these tokens to ensure they follow Python's grammar rules. It builds a tree structure—the Abstract Syntax Tree (AST). This represents the logical structure of your code, stripping away unrelated styling like indentation or comments.
3. Bytecode Generation
Finally, the AST is converted into bytecode.
What exactly is Bytecode? Bytecode is a low-level, platform-independent set of instructions. It’s called "bytecode" because each instruction opcode is typically one byte in size. Importantly, these instructions aren't for your CPU—they are for the Python Virtual Machine.
Key Takeaway: Bytecode is why Python is portable. You can compile Python code on Windows and run the resulting bytecode on Linux, provided the Python versions match.
The Mystery of __pycache__
If you've ever wondered why a __pycache__ folder suddenly appears in your project, this is the compilation step in action.
When Python compiles a script, it stores the resulting bytecode in memory. To optimize startup time for future runs, it saves that bytecode to disk in .pyc (Compiled Python) files.
When are .pyc files created?
It’s a common misconception that Python creates a .pyc file for every script.
Top-Level Scripts: If you run
python my_script.py, Python generally does not cache it to disk. The assumption is that the entry point changes frequently.Imported Modules: Python always tries to cache imported modules.
On subsequent runs, Python checks if the source .py file has changed. If it hasn't, it loads the .pyc file directly, skipping the compilation step entirely.
Stage 2: The Python Virtual Machine (PVM)
Once the bytecode is ready, it is handed to the PVM.
The PVM is the "engine" of Python. It is a piece of software (a loop written in C, if you're using CPython) that simulates a physical computer. It iterates through your bytecode instructions one by one and executes the corresponding machine-level operations.
The Execution Loop
The PVM is a stack-based machine. It doesn't use registers like a physical CPU does. Instead, it pushes values onto a stack and pops them off to perform operations.
At its core, the PVM logic looks somewhat like this (simplified pseudo-code):
while (have_more_instructions) {
opcode = fetch_next_instruction();
switch(opcode) {
case LOAD_CONST:
// Push a value onto the stack
push_to_stack(get_constant(argument));
break;
case BINARY_ADD:
// Pop two numbers, add them, push result back
right = pop_from_stack();
left = pop_from_stack();
push_to_stack(left + right);
break;
// ... hundreds more opcodes
}
}
Stage 3: Memory Management
As the PVM runs, it needs to create objects (integers, lists, functions) in RAM. Coming from a background in languages where you might manage memory manually, I find Python's automated approach fascinating.
Reference Counting: This is Python's primary technique. Every object keeps a running tally of how many references point to it. When a variable goes out of scope or is reassigned, the count drops. If it hits zero, the memory is immediately freed.
Garbage Collection (GC): Reference counting has a flaw—it can't handle "cyclic references" (where Object A points to Object B, and B points back to A). Python has a separate Garbage Collector that periodically scans for these disconnected cycles and cleans them up.
Practical Labs: Seeing it in Action
We don't have to take this theory on faith. One of the coolest things about Python is that it exposes its own internals to us.
Lab: Viewing Bytecode with dis
Python includes a "disassembler" module called dis that translates bytecode back into a human-readable format.
Let's analyze a simple function. Create a file called internal.py:
import dis
def greet(name):
# A simple f-string printing operation
print(f'Hello, {name}!')
# Disassemble the greet function to see the underlying bytecode
dis.dis(greet)
The Output: When you run this, you see the actual instructions the PVM executes for that single print statement:
5 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('Hello, ')
4 LOAD_FAST 0 (name)
6 FORMAT_VALUE 0
8 BUILD_STRING 2
10 CALL_FUNCTION 1
12 POP_TOP
14 LOAD_CONST 0 (None)
16 RETURN_VALUE
Decoding the PVM:
LOAD_GLOBAL: The PVM needs theprintfunction. It looks it up and pushes it onto the stack.LOAD_CONST&LOAD_FAST: It pushes the string literal'Hello, 'and the local variablenameonto the stack.BUILD_STRING: It pops the top two items and concatenates them.CALL_FUNCTION 1: It calls the function sitting at the bottom of the stack (print) using the 1 argument sitting on top of it.RETURN_VALUE: Every Python function returns something. Since we didn't specify one, it loadsNoneand returns it.
Conclusion
Understanding the PVM doesn't just make you feel like a wizard; it helps you write better code. Knowing that Python is a stack-based interpreter helps explain why certain operations are slower than others, and understanding the import caching mechanism helps debug those weird "why isn't my code updating" moments.



