Chapter 01 The Python Machine
Chapter 01 The Python Machine
CHAPTER ONE
You type python my_script.py and press Enter. Half a second later, your program runs. But
?what actually happened in that half second
Most Python developers carry a vague mental model: Python reads the code, figures out what it
means, and does it. That model is good enough to write scripts. But it is not good enough to be an
engineer. Engineers know what the machine is actually doing, because that knowledge is the
source of every performance intuition, every debugging insight, and every architectural decision that
.separates great code from merely working code
In this chapter, we will open the black box. We will follow a Python source file from the moment you
press Enter to the moment the last line executes — watching every transformation it undergoes
along the way. By the end, you will have a precise mental model of the Python runtime, and you will
.never look at a .py file the same way again
Before we examine the stages individually, let’s establish the full picture. When you execute a
Python script, your source code passes through a pipeline of transformations before any
.computation happens. Understanding this pipeline is the first step to thinking like an engineer
Notice that there are four distinct stages before your code produces any output. Python is not
simply ‘reading your script and doing what it says.’ It is compiling your code to an intermediate
representation, then running that representation inside a virtual machine. This distinction matters
.enormously for performance and debugging
ENGINEER'S NOTE
The word ‘interpreted’ is historically accurate but technically misleading when applied to
modern Python. CPython compiles your source to bytecode and then interprets that
bytecode — a two-stage process. True interpretation (reading source code and executing it
directly, character by character) would be far slower than what CPython actually does.
When someone says Python is slow because it’s interpreted, they are working from an
.incomplete model
The first thing CPython does with your source file is not ‘understand’ it — it’s to break it into atoms.
The lexer (also called a tokenizer) reads the raw text of your .py file character by character and
.produces a flat stream of tokens: the smallest meaningful units of Python syntax
:Consider this simple line of Python
x = 42 + y
To a human eye, this is an assignment. To the lexer, it is a sequence of tokens. Let’s see exactly
:what those tokens are
import tokenize
import io
'source = 'x = 42 + y
tokens = tokenize.generate_tokens([Link](source).readline)
:Output
Five tokens for three words and two symbols. The lexer doesn’t know what any of these tokens
mean — it doesn’t know that `x` is a variable name or that `=` is assignment rather than equality. It
.only knows token types: NAME, NUMBER, OP, NEWLINE, ENDMARKER
Python’s token types are defined in the `token` module. The most common ones you will encounter
:are
The lexer produces an INDENT token before `print` and a DEDENT token after `return name`. The
parser then uses these tokens to build nested structure. This is why a TabError or IndentationError
.occurs at the lexer stage — before Python even tries to understand the logic of your code
COMMON MISTAKE
Many developers think IndentationError is a syntax error. It is actually a lexer error —
CPython fails during tokenization, before parsing even begins. This is why the error
message is so precise about line and column: the lexer tracks exact character positions for
.every token it produces
CPYTHON SIDEBAR
The CPython lexer is implemented in Python/[Link] (pure Python, used by tools) and
in Parser/tokenizer.c (the C implementation used during actual execution). If you want to
read the real CPython tokenizer, start with cpython/Parser/tokenizer.c on GitHub. Look for
.the function tok_get() — it's the core loop that consumes characters and emits tokens
The token stream produced by the lexer is flat — a linear sequence with no structure. The parser’s
job is to impose structure by recognizing grammatical patterns and building a tree. This tree is
.called the Abstract Syntax Tree, or AST
The AST is ‘abstract’ because it captures the logical structure of your program without preserving
every syntactic detail. Parentheses, commas, and whitespace that were necessary for parsing are
discarded. What remains is a tree where every node represents a meaningful operation or
.construct
import ast
''' = source
:def add(a, b)
return a + b
'''
tree = [Link](source)
print([Link](tree, indent=4))
(Module
[=body
(FunctionDef
,'name='add
(args=arguments
[=args
,arg(arg='a')
arg(arg='b')
]
,)
[=body
(Return
(value=BinOp
,left=Name(id='a')
,)(op=Add
right=Name(id='b')
)
)
]
)
]
)
Read this tree from the outside in. At the top level is a Module — every Python file is a Module.
Inside the Module is a FunctionDef named 'add'. The function has two arguments, 'a' and 'b'. Its
body contains a single Return statement. The Return's value is a BinOp (binary operation) with left
.operand Name('a'), operator Add(), and right operand Name('b')
This tree is a complete, precise, unambiguous representation of your program's structure.
Everything the compiler needs to know is encoded here. Importantly, the AST has no idea what 'a'
and 'b' are — whether they are integers or strings, whether 'add' has been called before, whether
.any of this makes sense at runtime. The AST is a structural model, not a semantic one
• Static analysis tools like pylint, pyflakes, and mypy all operate on the AST. Understanding the
AST helps you understand what these tools can and cannot detect.
• Code formatters like Black and autopep8 parse source to an AST, then regenerate source
from the AST in a canonical format.
• Security scanners (bandit, semgrep) pattern-match on AST nodes to find vulnerabilities.
• You can write your own AST transformations using [Link] — a powerful
technique for code instrumentation, optimization, and domain-specific language
implementation.
Here is a practical example: a simple AST visitor that counts how many times each function is
:called in a codebase
import ast
import collections
:class CallCounter([Link])
""".Counts function calls in Python source code"""
:def __init__(self)
)([Link] = [Link]
''' = source
print(len([1, 2, 3]))
x = sorted(range(10))
print(x)
'''
tree = [Link](source)
)(counter = CallCounter
[Link](tree)
:Output #
print: 2 call(s) #
len: 1 call(s) #
sorted: 1 call(s) #
range: 1 call(s) #
ENGINEER'S NOTE
The call to self.generic_visit(node) in visit_Call is critically important. Without it, nested calls
like print(len(x)) would only count the outer call. generic_visit() continues traversal into child
.nodes. Forgetting this is the most common bug when writing AST visitors
CPYTHON SIDEBAR
Python’s grammar is defined in Grammar/[Link], a PEG grammar file. CPython 3.9+
uses a PEG parser (replacing the earlier LL(1) parser) implemented in Parser/parser.c
(auto-generated from the grammar). The grammar file is human-readable and is the
authoritative definition of Python syntax. If you ever wonder whether some syntax is valid
.Python, the grammar file gives the definitive answer
With a complete AST in hand, CPython’s compiler walks the tree and emits bytecode: a sequence
of simple instructions for the Python virtual machine. Bytecode is the bridge between human-
.readable Python and machine-executable operations
Bytecode is not machine code. It does not run directly on your CPU. Instead, it is a compact,
platform-independent set of instructions for a software abstraction called the CPython Virtual
Machine (the interpreter loop). This is the same design used by the Java Virtual Machine and
.the .NET CLR
import dis
:def add(a, b)
return a + b
[Link](add)
:Output
RESUME 0 0 4
LOAD_FAST 0 (a) 2 5
LOAD_FAST 1 (b) 4
)+( BINARY_OP 0 6
RETURN_VALUE 10
1. RESUME 0 — A Python 3.11+ instruction marking the start of a code object. The VM uses it
for tracing and profiling.
2. LOAD_FAST 0 (a) — Push the value of local variable at index 0 (which is `a`, the value 3)
onto the evaluation stack.
3. LOAD_FAST 1 (b) — Push the value of local variable at index 1 (which is `b`, the value 5)
onto the evaluation stack. Stack is now [3, 5].
4. BINARY_OP 0 (+) — Pop the top two values from the stack, add them, push the result. Stack
is now [8].
5. RETURN_VALUE — Pop the top of the stack (8) and return it to the caller.
Five instructions for one line of Python. And crucially: the VM executes these instructions in a tight
C loop, not by re-reading your source code. This is why Python is faster than true source
.interpretation — bytecode is compact and pre-analyzed
import dis
:def find_evens(numbers)
][ = result
:for n in numbers
:if n % 2 == 0
[Link](n)
return result
[Link](find_evens)
RESUME 0 0 2
BUILD_LIST 0 2 3
STORE_FAST 1 (result) 4
LOAD_FAST 0 (numbers) 6 4
GET_ITER 8
FOR_ITER 16 (to 44) 10 >>
STORE_FAST 2 (n) 12
LOAD_FAST 2 (n) 14 5
LOAD_CONST 1 (2) 16
)%( BINARY_OP 6 18
LOAD_CONST 2 (0) 22
)==( COMPARE_OP 2 24
POP_JUMP_FORWARD_IF_FALSE 6 (to 42) 28
LOAD_FAST 1 (result) 30 6
LOAD_METHOD 0 (append) 32
LOAD_FAST 2 (n) 54
PRECALL 1 56
CALL 1 60
POP_TOP 70
LOAD_FAST 1 (result) 76 7
RETURN_VALUE 78
• GET_ITER converts the iterable (numbers) into an iterator object, which tracks position.
• FOR_ITER is the heart of the loop. It calls the iterator's __next__ method. When the iterator is
exhausted, it jumps forward to END_FOR.
• JUMP_BACKWARD at the bottom of the loop body jumps back to FOR_ITER, implementing
the loop repetition.
• POP_JUMP_FORWARD_IF_FALSE implements the `if` condition: if the result of the
comparison is False, skip over the if-body.
• LOAD_METHOD + CALL is the pattern for method invocation ([Link](n)).
ENGINEER'S NOTE
The >> markers in the dis output indicate jump targets — places where control flow can
jump to from elsewhere. When debugging a performance issue, look for unexpected jump
patterns. A tight loop with many conditional jumps or nested loops is where Python spends
.the most time — each VM instruction has overhead that compounds in hot code paths
:def add(a, b)
return a + b
__co = add.__code
:Output #
'Bytecode: b'\x97\x00|\x00|\x01\xa0\x00S\x00 #
Constants: (None,) #
Local names: ('a', 'b') #
Argument count: 2 #
Stack size: 2 #
>Filename: <stdin #
First line: 1 #
The code object is immutable and shareable. Every time you call `add`, Python creates a new
frame (which we’ll discuss shortly) but reuses the same code object. This is an important
.optimization: the bytecode is compiled once and executed many times
Compilation from source to bytecode takes time. For small scripts it’s negligible, but for large
programs with many modules, compiling thousands of lines on every startup would be wasteful.
.CPython avoids this by caching the bytecode in .pyc files
The .pyc file contains a magic number (a version-specific constant), a timestamp or hash of the
source file, and the serialized code object. On subsequent imports, Python checks whether the
source has changed. If not, it loads the .pyc directly, skipping the lexer, parser, and compiler
.entirely
import py_compile
import marshal
import struct
print(f'Magic: {[Link]()}')
print(f'Source size: {[Link]("<I", source_size)[0]} bytes')
print(f'Code object type: {type(code_obj)}')
print(f'Constants in module: {code_obj.co_consts[:5]}')
ENGINEER'S NOTE
A common source of confusion: changes to a .py file are not picked up by Python until
the .pyc is invalidated. In development, this happens automatically via the timestamp
check. In production deployments, stale .pyc files can cause subtle bugs if the deployment
process copies .pyc files without corresponding .py changes. Always ensure your
deployment process either regenerates .pyc files or clears __pycache__ directories when
.deploying code changes
The bytecode is compiled. The code object is ready. Now the real work begins. The CPython Virtual
Machine — also called the interpreter, the eval loop, or sometimes just ‘the VM’ — executes the
.bytecode instructions one by one
The VM is a stack machine. Unlike register-based machines (like your CPU), which operate on
named registers, a stack machine operates on an implicit stack. Instructions pop values off the
stack, perform operations, and push results back on. The design is simpler to implement and
.generates compact bytecode, at the cost of some efficiency compared to register-based designs
import dis
[Link](compile('2 + 3 * 4', '<string>', 'eval'))
:Output #
RESUME 0 0 1 #
LOAD_CONST 0 (2) 2 #
LOAD_CONST 1 (12) <-- compiler computed 4 #
!3*4
)+( BINARY_OP 0 6 #
RETURN_VALUE 10 #
Interesting! The compiler’s constant folding optimization computed `3 * 4 = 12` at compile time, so
.the VM only needs to add 2 + 12. The compiler is smarter than it might appear
:For a non-constant example, let’s trace manually
:def compute(x, y)
return x + y * 2
Frames are linked together into the call stack. You can inspect the current frame and walk the stack
:using the `inspect` module
import inspect
:)(def inner
Inspect the call stack from inside a function #
)(frame = [Link]
print(f'Current function: {frame.f_code.co_name}')
print(f'Local variables: {frame.f_locals}')
print(f'Called from: {frame.f_back.f_code.co_name}')
:)(def outer
x = 10
)(inner
:)(def main
)(outer
)(main
:Output #
Current function: inner #
}{ :Local variables #
Called from: outer #
Frame objects are also what Python’s traceback machinery uses when an exception is raised. The
exception propagates up the frame chain until a matching except clause is found or the top-level
.frame is reached (producing the familiar traceback output)
The real ceval.c loop is approximately 3,000 lines of C code, with one case for each of Python’s
~150 opcodes. Modern CPython also uses computed gotos (a GCC extension) for faster dispatch,
and Python 3.11 added ‘specializing adaptive interpreter’ (PEP 659) that replaces frequently-
executed generic opcodes with faster specialized versions based on the types it observes at
.runtime
CPYTHON SIDEBAR
Python 3.11 introduced a major VM optimization called the Specializing Adaptive
Interpreter. When the VM notices that an instruction like BINARY_OP is always called with
integer arguments, it replaces that instruction with BINARY_OP_ADD_INT — a specialized
version that skips type-checking overhead. This is similar to JavaScript JIT compilation but
.simpler. The result is a 10–60% speedup for many workloads in Python 3.11 vs 3.10
Every function call creates a new frame. Python limits the call stack depth to prevent stack overflow.
:The default limit is 1,000 frames
import sys
:try
:countdown(2000) # With default limit of 1000, this raises
:except RecursionError as e
print(f'RecursionError: {e}')
ENGINEER'S NOTE
The recursion limit is not about stack safety in the C sense — Python frames are heap-
allocated Python objects. The limit exists because deeply recursive Python code tends to
indicate an algorithmic problem (like infinite recursion from a bug), and because very deep
recursion makes tracebacks almost unreadable. If you genuinely need deep recursion,
increasing the limit to 5,000–10,000 is usually fine. Beyond that, consider converting the
.recursion to an explicit stack-based loop
Let’s synthesize everything by tracing the complete execution of a small but realistic Python
.program from the moment you press Enter to the moment it exits
[Link] #
:def fibonacci(n)
""".Return the nth Fibonacci number"""
:if n <= 1
return n
return fibonacci(n - 1) + fibonacci(n - 2)
result = fibonacci(6)
print(f'fibonacci(6) = {result}')
import dis
:def fibonacci(n)
:if n <= 1
return n
return fibonacci(n - 1) + fibonacci(n - 2)
[Link](fibonacci)
:Output #
RESUME 0 0 2 #
#
LOAD_FAST 0 (n) 2 3 #
LOAD_CONST 1 (1) 4 #
)=<( COMPARE_OP 1 6 #
POP_JUMP_FORWARD_IF_FALSE 2 (to 16) 10 #
#
LOAD_FAST 0 (n) 12 4 #
RETURN_VALUE 14 #
#
LOAD_GLOBAL 1 (NULL + fibonacci) 16 >> 5 #
LOAD_FAST 0 (n) 28 #
LOAD_CONST 1 (1) 30 #
)-( BINARY_OP 10 32 #
CALL 1 36 #
LOAD_GLOBAL 1 (NULL + fibonacci) 46 #
LOAD_FAST 0 (n) 58 #
LOAD_CONST 1 (1) 60 #
)-( BINARY_OP 10 62 #
... #
10. The recursion bottoms out at n=0 and n=1, which return immediately
11. Return values are pushed onto the calling frame’s stack, added, and returned up the chain
12. After fibonacci(6) returns 8, it is stored in `result`
13. The print call formats and outputs `fibonacci(6) = 8`
At peak recursion depth for fibonacci(6), there are 7 active frames on the call stack (one for the
module scope plus 6 for the recursive calls). Each frame holds its own `n`, its own evaluation stack,
.and its own instruction pointer
INTERVIEW CHECKPOINT
• "Explain the difference between CPython's compilation and execution phases." —
Answer: CPython compiles Python source to bytecode in three stages (lex, parse,
compile), then executes the bytecode in the VM. This is why Python is 'compiled' in a
meaningful sense, even though it doesn't produce native machine code.
• "What is a code object vs a frame object?" — Code objects are immutable, compiled
representations of a function (created once). Frame objects are per-call execution
contexts (created each time a function is called).
• "Why does Python have a recursion limit?" — To prevent infinite recursion from
consuming unbounded memory. Python frames are heap objects, not C stack frames,
but CPython itself uses C recursion internally which is bounded by the OS stack.
• "What does [Link]() show you?" — The bytecode disassembly of a function: the
sequence of VM instructions with their arguments and corresponding source line
numbers.
The `dis` module deserves its own dedicated section because it is one of the most powerful tools
for understanding why Python code behaves the way it does. Professional engineers use `dis` to
.diagnose performance, understand optimizer behavior, and build intuition about cost
import dis
import timeit
:def square_power(x)
return x ** 2
:def square_multiply(x)
return x * x
Timing comparison #
t1 = [Link]('square_power(7)', globals=globals(), number=1_000_000)
t2 = [Link]('square_multiply(7)', globals=globals(), number=1_000_000)
print(f'square_power: {t1:.3f}s')
print(f'square_multiply: {t2:.3f}s')
LOAD_FAST 0 (x) 2
LOAD_FAST 0 (x) 4
)*( BINARY_OP 5 6
RETURN_VALUE 10
square_power: 0.087s
square_multiply: 0.041s
Same number of instructions, but `x * x` is about twice as fast. Why? Because `**` calls Python’s
general power operation (which handles complex numbers, negative exponents, and arbitrary
precision integers), while `*` for small integers uses a highly optimized code path. The `dis` output
.shows identical instruction counts but conceals the difference in instruction cost
This is an important lesson: bytecode instruction count is not the same as execution time. Some
.instructions are orders of magnitude more expensive than others
import dis
:def loop_version(data)
][ = result
:for x in data
[Link](x * 2)
return result
:def comprehension_version(data)
return [x * 2 for x in data]
:Output #
Loop version: 18 instructions #
Comprehension version: 9 instructions (outer function) #
)Plus a separate code object for the comprehension itself( #
List comprehensions generate a separate code object for their body. This means the loop happens
in a dedicated scope with optimized local variable access (LOAD_FAST instead of LOAD_GLOBAL
for `append`). This is why comprehensions are typically 15–35% faster than equivalent for loops
.with .append() calls — not magic, but a measurable bytecode and scope difference
14. Profile first: use cProfile or line_profiler to identify the slow function
15. Disassemble the slow function with [Link]()
16. Look for expensive patterns: LOAD_GLOBAL (expensive) vs LOAD_FAST (cheap), method
lookups, repeated attribute access
17. Rewrite the function to eliminate expensive patterns
18. Verify the improvement with [Link]() and confirm with timeit
:def slow_version(n)
result = 0
:for i in range(n)
result += [Link](i) # LOAD_GLOBAL for math, then LOAD_ATTR for
sqrt
return result
:def fast_version(n)
sqrt = [Link] # Cache the global lookup as a local
variable
result = 0
:for i in range(n)
result += sqrt(i) # LOAD_FAST for sqrt -- much cheaper
return result
:Typical output #
Slow: 0.312s #
Fast: 0.198s #
Speedup: 1.6x #
You now understand what actually happens when Python runs. This is not academic knowledge —
it is the foundation of every performance optimization and debugging technique in the chapters
:ahead. Let’s consolidate the key concepts
This model will serve you throughout this book. When we talk about algorithmic complexity in
Chapter 5, you will understand that each ‘operation’ in our analysis corresponds to a bytecode
instruction (or several). When we analyze data structure costs in Chapter 4, you will know that ‘O(1)
lookup’ means a small, fixed number of VM instructions regardless of collection size. When we
.discuss the GIL in Chapter 3, you will understand what it’s protecting: the VM's internal state
.Engineers don’t guess. They know
EXERCISES
Tier 1 — Recall
19. List the five stages of the CPython execution pipeline in order.
20. What does `co_varnames` contain on a code object? What about `co_consts`?
21. What is the difference between a code object and a frame object?
22. Why does Python only create .pyc files for imported modules, not for scripts run
directly?
Tier 2 — Apply
23. Use [Link]() to inspect the following function. How many instructions does the loop
body execute per iteration? Which instruction is the most expensive, and why? def
sum_squares(n): total = 0 for i in range(n): total += i ** 2 return total
24. Write an [Link] that counts the total number of if statements and for loops
in a Python source file. Test it on [Link] from this chapter.
25. Compare the bytecode of these two implementations using [Link](). Explain the
difference and predict which will be faster: def v1(items): return [x for x in items if x >
0] def v2(items): return list(filter(lambda x: x > 0, items))
Tier 3 — Design
26. Write a function `bytecode_complexity(func)` that returns the number of unique
opcodes used by a function and a dictionary mapping each opcode name to how
many times it appears. Use dis.get_instructions(). What does this metric tell us about
a function’s computational character?
27. Design a simple Python decorator @trace_calls that, when applied to a function,
prints the name of every function called within that function’s execution. (Hint: look at
[Link]() — it gives you a callback on every frame event, including 'call' events.)
What are the performance implications of using settrace in production?
Coming Up in Chapter 2
Memory: Where Your Objects Actually Live
Now that you know how Python executes code, Chapter 2 asks: where does everything live
while it’s executing? We will explore Python’s object model, the heap, reference counting, and
the cyclic garbage collector — understanding not just what Python does with your objects, but
.when and why it decides to destroy them