You type python checkout.py and a line of output appears. Between those two things, Python does more work than the word "interpreted" suggests. This chapter traces what happens from the moment you save a .py file to the moment your CPU runs the code, and clears up the most common misconception about Python: that it's just an interpreter reading your source line by line.
Python's execution pipeline has two halves. The first half compiles your source code into bytecode. The second half feeds that bytecode to the Python Virtual Machine (PVM), which executes it instruction by instruction. Both halves usually happen inside the same python process, in the same second, which is why it feels like one step from the outside.
Here's the full path from a .py file to actual output on your screen.
The first four boxes (source through bytecode) are the compilation stage. The PVM box is the runtime. There's no separate build command like javac or gcc. When you run python checkout.py, the python executable does both jobs back to back: compile first, then execute.
We'll walk each stage using a tiny example.
That output is the end of the pipeline. Let's start at the beginning.
When you run python checkout.py, the interpreter does three things before any of your code starts running:
def, cart_total, (, price, etc.), and the parser arranges those tokens into an Abstract Syntax Tree (AST). The AST is a tree of nodes that represent the structure of your program: a function definition contains parameters and a body, the body contains a return statement, the return statement contains a multiplication expression.The important point: this isn't machine code. The CPU sitting in your laptop has no idea what BINARY_OP means. Bytecode targets an abstract machine, and the PVM is the program that makes that abstract machine real.
This is why calling Python "interpreted" is half the truth. Your source code is compiled to bytecode first. Only the bytecode is then interpreted by the PVM. So Python is more accurately described as "compiled to bytecode, then run on a virtual machine."
__pycache__)If Python recompiles your source every time you run it, that's wasted work. Most of the time the source hasn't changed since the last run. Python avoids the redundant work by caching the compiled bytecode on disk.
The cache lives in a folder named __pycache__, created next to your .py files. Inside it, you'll find files like:
The naming pattern is {module}.{implementation}-{version}.pyc. The implementation tag (cpython) and version (311, 312) keep caches from different Python builds isolated, so a .pyc compiled by Python 3.11 won't accidentally be reused by Python 3.12.
Each time Python imports a module, it checks whether a matching .pyc already exists and whether the source file has changed since that .pyc was written. If the cache is fresh, Python skips the parse and compile steps and loads the bytecode directly. If the source has been edited, Python recompiles and updates the .pyc.
A few practical points worth knowing:
__pycache__ is created automatically. You don't manage it.__pycache__/ and *.pyc to .gitignore. The cache is a build artifact, not source.python checkout.py) usually don't get cached. The cache mainly helps imported modules, since those are what get reused across runs.Cost: Cold-starting a large Python application can take noticeably longer the first time because every imported module gets parsed and compiled. Subsequent runs are faster because __pycache__ already holds the bytecode.
The Python Virtual Machine is the program that actually runs your bytecode. It's a stack-based virtual machine, which means most of its instructions push values onto an evaluation stack, pop values off, and push results back.
To make that concrete, consider what price * quantity looks like to the PVM:
price onto the stack.quantity onto the stack.That's it. No registers, no memory addressing. Every operation flows through the stack. This design keeps the bytecode small and simple to interpret, at the cost of running slower per operation than register-based machine code on a real CPU.
The PVM is implemented as a giant loop in C (the actual loop lives in CPython's ceval.c). The loop reads one bytecode instruction, dispatches to the C function that implements it, and moves on to the next instruction. That loop is the engine running every Python program in the world.
One implication is that "Python is slow on CPU-bound work" is partly a comment on this design. The PVM does much more work per instruction than a CPU does for a native instruction. Languages with a Just-In-Time compiler (like PyPy) can sidestep this by translating hot bytecode to native code at runtime, similar to how the JVM works. Standard CPython doesn't do that, which is why CPU-heavy Python code often hands off the actual computation to C libraries like NumPy.
You don't have to take the existence of bytecode on faith. Python ships a module called dis ("disassemble") that shows you the bytecode for any function.
A few instructions are worth pointing out:
LOAD_FAST pushes a local variable onto the stack. LOAD_FAST 0 (price) pushes the first parameter; LOAD_FAST 1 (quantity) pushes the second.BINARY_OP 5 (*) pops the top two stack values, multiplies them, and pushes the product back. The 5 is the internal opcode for multiplication.RETURN_VALUE pops the top of the stack and returns it to the caller.The exact opcodes and numbering shift between Python versions. Python 3.11 introduced RESUME and consolidated arithmetic operations under BINARY_OP. Older versions had separate BINARY_MULTIPLY, BINARY_ADD, and so on. Don't memorize the instruction set. The point is that the PVM is reading a real, inspectable program, not your Python source.
The dis module is genuinely useful when you want to understand why two pieces of code that look the same have different performance, or what a comprehension actually compiles to.
"Python" is a language specification. Many programs implement that specification, and they all run Python code, but they do it differently under the hood.
Here's what each one is for:
| Implementation | Written In | Best For | Notes |
|---|---|---|---|
| CPython | C | Almost everything | The reference implementation. What you download from python.org. When people say "Python", this is what they mean by default. |
| PyPy | RPython | CPU-bound Python that runs for a while | Includes a Just-In-Time compiler that converts hot bytecode to native machine code. Often several times faster than CPython on long-running numerical or algorithmic code. |
| Jython | Java | Embedding Python inside Java applications, scripting on the JVM | Compiles Python to JVM bytecode and runs it inside the Java Virtual Machine. Lets Python code call Java libraries directly. |
| IronPython | C# | Running Python on the .NET runtime | Targets the .NET Common Language Runtime. Useful when you need Python to integrate with C# code. |
| MicroPython | C | Microcontrollers, embedded systems | A small Python that fits on devices with kilobytes of RAM (Raspberry Pi Pico, ESP32, etc.). Implements a subset of the standard library. |
For 99% of work (web apps, scripts, data processing, machine learning, scripting), you'll use CPython without thinking about it. PyPy is the most common alternative people reach for when CPython is the bottleneck on a workload that can't be moved into a C library. The others matter mostly when you're crossing a platform boundary, like running Python inside a Java server or on a microcontroller.
One thing to keep straight: when this chapter talks about "the PVM" and "bytecode", it specifically means CPython's PVM and CPython's bytecode. PyPy has its own intermediate representation, Jython compiles to JVM bytecode, and so on. The user-facing language is the same, but the internals are entirely different runtimes.
CPython has a piece of internal machinery called the Global Interpreter Lock, or GIL. It's a mutex that allows only one thread to execute Python bytecode at a time, even if your machine has multiple CPU cores. The GIL exists to keep CPython's memory management simple and fast for single-threaded code, but it means that multi-threaded CPython programs do not get true parallel execution for CPU-bound work. Workarounds include using multiprocessing instead of threads, calling into C extensions (like NumPy) that release the GIL, or switching to a GIL-free implementation. Note that PyPy also has a GIL; Jython and IronPython do not, because they rely on their host runtime's threading model.
You never call free or delete in Python. The runtime tracks how many references point to each object, and when that count drops to zero, the object is freed immediately. This is called reference counting, and it handles the vast majority of cleanup in CPython. Reference counting alone can't break circular references (object A holds a reference to B, B holds a reference to A, nothing else points to either), so CPython also runs a periodic cyclic garbage collector that finds and reclaims those cycles. You can interact with it through the gc module, but you usually don't need to.
10 quizzes