0% found this document useful (0 votes)
4 views28 pages

Chapter 01 The Python Machine

Chapter 1 of the document explains the process of how Python code is executed, detailing the five-stage pipeline from source code to execution. It covers the roles of the lexer, parser, and CPython virtual machine, emphasizing the importance of understanding these stages for effective debugging and performance optimization. The chapter also introduces key concepts such as bytecode, the Abstract Syntax Tree (AST), and how they relate to Python's execution model.
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
4 views28 pages

Chapter 01 The Python Machine

Chapter 1 of the document explains the process of how Python code is executed, detailing the five-stage pipeline from source code to execution. It covers the roles of the lexer, parser, and CPython virtual machine, emphasizing the importance of understanding these stages for effective debugging and performance optimization. The chapter also introduces key concepts such as bytecode, the Abstract Syntax Tree (AST), and how they relate to Python's execution model.
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd

Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

CHAPTER ONE

The Python Machine


How Code Becomes Execution

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

What You Will Understand After This Chapter


• The five-stage pipeline from .py source file to executing program
• What bytecode is, why it exists, and how to read it
• How the CPython virtual machine executes your code instruction by instruction
• What a stack frame is and how Python manages function calls
• Why 'interpreted' doesn’t mean what most people think it means

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.1 The Journey of a Python Program

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

Source Code Lexer & Compiler CPython VM


(.py file) → Tokenizer → Parser (AST) → (Bytecode) → (Execution)

Figure 1.1 — The CPython execution pipeline

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

1.2 Stage One: Lexing and Tokenization

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

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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)

:for tok in tokens


print(tok)

:Output

TokenInfo(type=1 (NAME), string='x', start=(1,0), end=(1,1))


TokenInfo(type=54 (OP), string='=', start=(1,2), end=(1,3))
TokenInfo(type=2 (NUMBER), string='42', start=(1,4), end=(1,6))
TokenInfo(type=54 (OP), string='+', start=(1,7), end=(1,8))
TokenInfo(type=1 (NAME), string='y', start=(1,9), end=(1,10))
TokenInfo(type=4 (NEWLINE),string='\n', start=(1,10),end=(1,11))
TokenInfo(type=0 (ENDMARKER),string='', start=(2,0), end=(2,0))

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

CONCEPT KEY TAKEAWAY


NAME Identifiers: variable names, function names, keywords like `if`,
``def`, `return
NUMBER Integer and float literals: 42, 3.14, 0xFF
STRING '''String literals: 'hello', "world", '''multiline
OP , ,: ,] ,[ ,) ,( ,== ,= ,- ,+ :Operators and punctuation
NEWLINE Logical line endings (may differ from physical newlines with
backslash continuation)
INDENT / DEDENT Python's indentation tokens — how the lexer represents block
structure without braces
COMMENT Anything after # (stripped before further processing)

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

Indentation as Tokens: Python’s Invisible Syntax


One of Python’s most important lexer behaviors is how it handles indentation. Unlike C, Java, or
JavaScript, Python has no curly braces to mark code blocks. Instead, the lexer tracks indentation
levels and generates INDENT and DEDENT tokens whenever indentation increases or decreases.
.This is how Python converts what looks like visual formatting into actual structure

def greet(name): # INDENT token generated here


print('Hello') # inside the block
return name # still inside
DEDENT token generated here #
greet('Alice')

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

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.3 Stage Two: Parsing and the Abstract Syntax


Tree

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

:Let’s look at the AST for a simple function

import ast

''' = source
:def add(a, b)
return a + b
'''

tree = [Link](source)
print([Link](tree, indent=4))

:Output (abbreviated for readability)

(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')

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

)
)
]
)
]
)

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

AST Node Types: A Field Guide


:The `ast` module exposes all node types as Python classes. The most important categories are

CONCEPT KEY TAKEAWAY


Module, FunctionDef, ClassDef Top-level and structural nodes
Assign, AugAssign, Assignment operations (x=1, x+=1, x:int=1)
AnnAssign
If, For, While, With, Try Control flow nodes — each has a body and optional branches
Return, Yield, Raise, Break Flow-control statements
Call, BinOp, UnaryOp, Expression nodes — operations that produce values
Compare
Name, Constant, Attribute Leaf nodes — variable references, literals, attribute access
Import, ImportFrom Module import statements

Why Engineers Care About the AST


The AST is not an implementation detail that only CPython developers need to know. It is actively
:useful in everyday engineering work

• 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.

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

• 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]

:def visit_Call(self, node)


[Link] is the thing being called #
:if isinstance([Link], [Link])
Simple call: func_name(args) #
[Link][[Link]] += 1
:elif isinstance([Link], [Link])
Method call: [Link](args) #
[Link][[Link]] += 1
IMPORTANT: visit child nodes too #
self.generic_visit(node)

''' = source
print(len([1, 2, 3]))
x = sorted(range(10))
print(x)
'''

tree = [Link](source)
)(counter = CallCounter
[Link](tree)

:)(for name, count in [Link].most_common


print(f'{name}: {count} call(s)')

:Output #
print: 2 call(s) #
len: 1 call(s) #
sorted: 1 call(s) #
range: 1 call(s) #

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.4 Stage Three: Compilation to Bytecode

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

Inspecting Bytecode with `dis`


Python ships with the `dis` module (short for ‘disassemble’), which lets you inspect the bytecode of
any Python function or module. This is one of the most important diagnostic tools in a Python
.engineer’s toolkit

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

:Let’s decode each column

CONCEPT KEY TAKEAWAY


Column 1 (4, 5) Source line number this bytecode corresponds to
Column 2 (0, 2, 4...) Byte offset within the bytecode sequence (each instruction takes 2

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

bytes in Python 3.6+)


Column 3 (LOAD_FAST...) The opcode — the operation the VM will perform
Column 4 (0, 1, 0) The opcode argument — an index into locals, constants, or names
Column 5 (a, b, +) Human-readable version of the argument (added by dis for
readability)

Reading the Bytecode Step by Step


:`Let’s trace through what happens when the VM executes `add(3, 5)

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

A Richer Example: Loops and Conditionals


:Let’s look at the bytecode for something more realistic

import dis

:def find_evens(numbers)
][ = result
:for n in numbers
:if n % 2 == 0
[Link](n)
return result

[Link](find_evens)

:Output (Python 3.11)

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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

JUMP_BACKWARD 32 (to 10) 72 >> 4


END_FOR 74 >>

LOAD_FAST 1 (result) 76 7
RETURN_VALUE 78

:Several important patterns appear here

• 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

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

Code Objects: The Bytecode Container


Bytecode does not exist in isolation. It is stored in a code object, a Python object that contains
everything the VM needs to execute a function. You can inspect the code object of any function via
:its `__code__` attribute

:def add(a, b)
return a + b

__co = add.__code

print('Bytecode: ', co.co_code[:10]) # raw bytes


print('Constants: ', co.co_consts) # literal values
print('Local names: ', co.co_varnames) # local variable names
print('Argument count: ', co.co_argcount) # number of positional args
print('Stack size: ', co.co_stacksize) # max stack depth needed
print('Filename: ', co.co_filename) # source file
print('First line: ', co.co_firstlineno) # starting line number

: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

CONCEPT KEY TAKEAWAY


co_code The raw bytecode bytes — the actual instructions
co_consts Tuple of constants used in the code (numbers, strings, None)
co_varnames Local variable names, indexed by position
co_names Global and attribute names referenced in the code
co_freevars Variables captured from enclosing scopes (closures)
co_cellvars Variables that inner functions capture from this function
co_stacksize Maximum evaluation stack depth needed (pre-calculated by
compiler)
co_argcount Number of positional arguments

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

co_flags ?Bit flags: is this a generator? Does it use *args? **kwargs

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.5 The .pyc File and the Compilation Cache

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

Where .pyc Files Live


When Python imports a module (as opposed to running a script directly), it saves the compiled
bytecode in a __pycache__ directory next to the source file. The filename encodes the Python
:version and a hash of the source

:After importing a module called '[Link]', you will find #


/__pycache__
[Link]

:The filename format is #


pyc.}python_tag{.}module_name{ #
python_tag = cpython-311 for CPython 3.11 #

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

Inspecting a .pyc File


:The `py_compile` and `marshal` modules let you compile and inspect .pyc files programmatically

import py_compile
import marshal
import struct

Compile a source file to .pyc #


py_compile.compile('my_module.py', cfile='my_module.pyc')

Read and inspect the .pyc file #


:with open('my_module.pyc', 'rb') as f

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

magic = [Link](4) # Magic number (version-specific)


bit_field = [Link](4) # Flags (hash-based or timestamp-based)
timestamp = [Link](4) # Source modification timestamp
source_size = [Link](4) # Source file size
code_obj = [Link](f) # The code object

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

When .pyc Files Are NOT Created


Python only creates .pyc files for modules that are imported, not for scripts run directly. If you run
`python my_script.py`, no .pyc file is created. This is intentional: scripts are typically run once, while
modules are imported repeatedly. The compilation overhead for a directly-run script is considered
.acceptable
You can also disable .pyc creation entirely with the `-B` flag or the
`PYTHONDONTWRITEBYTECODE` environment variable, which is useful in containerized
.environments where the filesystem is read-only or where you want to minimize disk I/O

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.6 Stage Four: The CPython Virtual Machine

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

The Evaluation Stack


Every function execution has its own evaluation stack. Let’s trace through `2 + 3 * 4` to see the
:stack in action. Due to operator precedence, this computes `3 * 4` first, then adds 2

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

:Bytecode trace for x=3, y=4 #


#
LOAD_FAST x Stack: [3] #
LOAD_FAST y Stack: [3, 4] #
LOAD_CONST 2 Stack: [3, 4, 2] #
BINARY_OP * Stack: [3, 8] (popped 4,2, pushed 8) #

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

BINARY_OP + Stack: [11] (popped 3,8, pushed 11) #


RETURN_VALUE Stack: [] (popped 11, returned it) #

Stack Frames: The Call Stack


When Python calls a function, it creates a frame object. A frame is a Python object that contains
:everything needed to execute one function call

• A reference to the code object being executed


• The current instruction pointer (which bytecode instruction to execute next)
• The local variables for this call
• The evaluation stack for this call
• A reference to the calling frame (the frame that called this function)

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)

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

The Main Interpreter Loop


At the heart of the CPython VM is the main interpreter loop, implemented in Python/ceval.c. This is
:a giant C switch statement that dispatches on each bytecode opcode. In simplified pseudocode

Simplified pseudocode of CPython's ceval.c main loop #


:def _PyEval_EvalFrameDefault(frame)
:while True
opcode = [Link][frame.instruction_pointer]
frame.instruction_pointer += 1

:if opcode == LOAD_FAST


arg = next_arg(frame)
[Link]([Link][arg])

:elif opcode == STORE_FAST


arg = next_arg(frame)
)([Link][arg] = [Link]

:elif opcode == BINARY_OP


)(right = [Link]
)(left = [Link]
result = apply_op(opcode_arg, left, right)
[Link](result)

:elif opcode == RETURN_VALUE


)(return [Link]

more opcodes 100~ ... #

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

Understanding Recursion Limits

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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

print([Link]()) # Default: 1000

:You can increase it (carefully) #


[Link](5000)

,But be careful: Python frames are Python objects on the heap #


,not C stack frames. The real limit is your available memory #
not the C call stack. However, CPython itself uses C recursion #
.internally, which IS bounded by the OS stack size #

:A recursive function that hits the limit #


:def countdown(n)
:if n == 0
return 0
return countdown(n - 1) # Each call creates a new frame

:try
:countdown(2000) # With default limit of 1000, this raises
:except RecursionError as e
print(f'RecursionError: {e}')

Output: RecursionError: maximum recursion depth exceeded #

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

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.7 Putting It All Together: Tracing a Full


Execution

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}')

Step 1: Shell to Python


You type `python [Link]`. The operating system launches the CPython process. CPython’s
main() function in Modules/main.c parses command-line arguments, initializes the interpreter
(setting up the memory allocator, built-ins, import system, and sys module), then hands control to
.the execution engine

Step 2: Source File is Read and Lexed


CPython opens [Link] and reads its contents. The lexer (tokenizer) processes the text and
:produces a token stream

:Token stream (abbreviated) #


'NAME 'def
'NAME 'fibonacci
'(' OP
'NAME 'n
')' OP
':' OP
NEWLINE
INDENT
'""".STRING '"""Return the nth Fibonacci number
NEWLINE
'NAME 'if
and so on ... #

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

Step 3: Parser Builds the AST


The parser consumes the token stream and produces an AST. The top-level Module node contains
two children: a FunctionDef node for `fibonacci` and an Assign node for `result = fibonacci(6)`,
.`followed by a Call to `print

Step 4: Compiler Emits Bytecode


The compiler walks the AST and produces code objects. It creates one code object for the module
:scope and one for the `fibonacci` function. Let’s inspect the function's bytecode

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 #
... #

Step 5: VM Executes fibonacci(6)


:The VM starts executing the module code object. When it hits `result = fibonacci(6)`, it

6. Looks up `fibonacci` in the global namespace (finds the function object)


7. Creates a new frame for fibonacci with n=6
8. Executes the bytecode: n=6 > 1, so it makes two recursive calls
9. Each recursive call creates a new frame, linked to the previous

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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.

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.8 The `dis` Module: Your Engineering X-Ray

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

Comparing Two Implementations


:A classic question: is `x ** 2` or `x * x` faster for squaring a number? Let’s let `dis` tell us

import dis
import timeit

:def square_power(x)
return x ** 2

:def square_multiply(x)
return x * x

print('=== square_power ===')


[Link](square_power)
)(print
print('=== square_multiply ===')
[Link](square_multiply)
)(print

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')

=== square_power ===


RESUME 0 0 2
LOAD_FAST 0 (x) 2
LOAD_CONST 1 (2) 4
)**( BINARY_OP 12 6
RETURN_VALUE 10

=== square_multiply ===


RESUME 0 0 2

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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

Understanding List Comprehensions vs For Loops


A perennial Python debate: are list comprehensions faster than equivalent for loops? `dis` reveals
:why

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]

print('=== Loop version instruction count ===')


Count instructions #
loop_instructions = list(dis.get_instructions(loop_version))
comp_instructions = list(dis.get_instructions(comprehension_version))

print(f'Loop version: {len(loop_instructions)} instructions')


print(f'Comprehension version: {len(comp_instructions)} instructions')

:Output #
Loop version: 18 instructions #
Comprehension version: 9 instructions (outer function) #
)Plus a separate code object for the comprehension itself( #

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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

Practical Workflow: Using dis for Optimization


:Here is a professional workflow for using `dis` when optimizing Python code

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

Common optimization: cache a global as a local #

import dis, timeit, math

: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

t1 = [Link](lambda: slow_version(10000), number=100)


t2 = [Link](lambda: fast_version(10000), number=100)
print(f'Slow: {t1:.3f}s')
print(f'Fast: {t2:.3f}s')
print(f'Speedup: {t1/t2:.1f}x')

:Typical output #
Slow: 0.312s #
Fast: 0.198s #
Speedup: 1.6x #

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

1.9 Chapter Summary

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

CONCEPT KEY TAKEAWAY


Lexer / Tokenizer Breaks source text into tokens (NAME, NUMBER, OP,
INDENT/DEDENT). Runs before any syntax checking.
.IndentationError is a lexer error
Parser / AST Converts tokens into a tree of nodes representing program
structure. Exposed via the ast module. Powers linters, formatters,
.and static analyzers
Compiler Walks the AST and emits bytecode. Creates code objects. Applies
optimizations like constant folding. Bytecode is cached in .pyc files
.__in __pycache
Code Object Immutable container for bytecode, constants, variable names, and
metadata. Created once per function definition and reused across
.calls
Frame Object Mutable per-call execution context: local variables, evaluation
stack, instruction pointer. Created on every function call, destroyed
.on return
CPython VM The main eval loop in ceval.c. A stack machine that dispatches on
each opcode. Python 3.11+ includes a specializing adaptive
.interpreter for typed fast paths
dis module Disassembles bytecode into human-readable form. Essential for
understanding performance, comparing implementations, and
.debugging unexpected behavior
pyc files. Cached compiled bytecode in __pycache__. Invalidated when the
source file changes. Created only for imported modules, not
.directly run scripts

The Mental Model You Now Have


When you write a Python function, you are writing a specification for a bytecode compiler. The
compiler translates your intent into a sequence of stack-machine instructions. Those instructions
are then executed by a C interpreter loop at CPython's core. Every Python operation — every
assignment, every attribute lookup, every loop iteration — corresponds to one or more bytecode
.instructions, each of which incurs a measurable cost

Chapter 1 Phase I — Python Under the Hood


Chapter 1: The Python Machine FROM PYTHON WRITER TO SOFTWARE ENGINEER

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

Chapter 1 Phase I — Python Under the Hood

You might also like