🎯 Learning Objectives
- Understand CPython's compilation pipeline: source → AST → bytecode → execution
- Inspect bytecode with
dismodule and understand the most important opcodes - Understand what a frame object (
PyFrameObject) is and what it contains - Trace the CPython eval loop (
ceval.c) conceptually and understand how it dispatches opcodes - Use
codeobjects: inspectco_consts,co_varnames,co_code,co_flags - Understand the value stack and how it is used during bytecode execution
- Use
sys._getframe(),inspect.currentframe(), and frame introspection for advanced tooling
1 · The CPython Compilation Pipeline
Before any Python code runs, CPython transforms your source text through a series of well-defined stages. Understanding this pipeline lets you reason about import caching, write AST-level transformations, and demystify error messages that reference column numbers, tokens, or "compile time".
Source code (.py)
↓ tokenise (tokenize module)
Token stream
↓ parse (Python's PEG parser, Grammar/python.gram)
Abstract Syntax Tree (ast module)
↓ compile (compile() / PyAST_CompileObject)
Code object (types.CodeType)
↓ execute (ceval.c — the eval loop)
Result
Tokenisation
The tokenize module turns raw source bytes into a flat stream of
tokens — each a 5-tuple
(type, string, start, end, line). Token types include
NAME, NUMBER, STRING, OP,
NEWLINE, INDENT, DEDENT, and
COMMENT. Tokenisation is line-oriented and requires no knowledge of
grammar — it simply identifies the atoms of the language.
Parsing
Since Python 3.9, CPython uses a hand-generated
PEG parser (Parsing Expression Grammar) defined in
Grammar/python.gram and compiled by the
Tools/peg_generator tool. The parser consumes the token stream and
emits an Abstract Syntax Tree — a tree of
ast.AST subclass instances (ast.Module,
ast.FunctionDef, ast.BinOp, …). The AST is fully exposed
to Python via the ast standard-library module, which enables powerful
metaprogramming and static analysis.
Compilation
compile(source, filename, mode) takes source text (or an already-parsed
AST) and returns a types.CodeType object. Internally CPython performs
name-resolution (distinguishing local, enclosing, global, and built-in scopes),
constant folding, and bytecode generation. The
mode argument is "exec" for statements,
"eval" for a single expression, or "single" for interactive
REPL mode.
Execution
exec() and every function call push a new frame onto the call
stack and hand it to the eval loop in Python/ceval.c. The loop reads
opcodes one by one and dispatches them to C handlers that manipulate a value stack of
PyObject* pointers.
import tokenize, ast, dis, io
source = """
def greet(name):
return f"Hello, {name}!"
"""
# Step 1 — tokens
tokens = tokenize.tokenize(io.BytesIO(source.encode()).readline)
for tok in tokens:
print(tok)
# Step 2 — AST
tree = ast.parse(source)
print(ast.dump(tree, indent=2))
# Step 3 — code object
code = compile(source, "<string>", "exec")
print(type(code)) # <class 'code'>
print(code.co_consts) # constants pool
# Step 4 — disassemble
dis.dis(code)pipeline_demo.pyimport a module, CPython compiles it to a .pyc file
(cached bytecode) and skips re-compilation on subsequent imports if the source
hasn't changed. The .pyc format is: magic number + timestamp/hash +
marshalled code object. The magic number encodes the CPython version, which is why
.pyc files from Python 3.11 cannot be loaded by Python 3.12.
2 · Code Objects
A code object (types.CodeType) is the compiled,
immutable representation of a function body, class body, module, or comprehension.
It encodes everything the eval loop needs to execute that block of code — the raw
bytecode instructions, the names of variables, the constants pool, and several
flags that control runtime behaviour. Code objects are not callable
directly; they become callable only when wrapped in a function object
(which binds the code object to a global namespace and a default-arguments tuple).
import types
def add(x, y):
z = x + y
return z
code = add.__code__ # every function carries its code object
print(type(code)) # <class 'code'>
print(code.co_name) # 'add'
print(code.co_filename) # file where defined
print(code.co_firstlineno) # 1
print(code.co_argcount) # 2 (x, y)
print(code.co_varnames) # ('x', 'y', 'z') — local var names
print(code.co_consts) # (None,) — constants (no numeric literals here)
print(code.co_names) # () — global names referenced
print(code.co_cellvars) # () — vars captured by inner closures
print(code.co_freevars) # () — vars captured FROM outer scope
print(code.co_stacksize) # max value stack depth needed
print(code.co_flags) # bit flags (CO_OPTIMIZED, CO_NEWLOCALS, CO_VARARGS…)
print(code.co_nlocals) # 3 (x, y, z)code_objects.pyKey co_* Attributes
| Attribute | Type | Description |
|---|---|---|
co_name | str | Function/class/module name |
co_qualname | str | Fully qualified name (Python 3.11+) |
co_filename | str | Source file |
co_firstlineno | int | First line of the function |
co_argcount | int | Number of positional args (not *args/**kwargs) |
co_varnames | tuple[str] | All local variable names (args first, then locals) |
co_consts | tuple | Constants pool (literals, nested code objects) |
co_names | tuple[str] | Global names referenced |
co_cellvars | tuple[str] | Locals captured by nested functions |
co_freevars | tuple[str] | Names captured from enclosing scope |
co_code | bytes | Raw bytecode (Python ≤ 3.10); use co_code or co_consts |
co_stacksize | int | Max operand stack depth needed |
co_flags | int | Bitmask of CO_* flags |
co_varnames always starts with argument names,
then adds locals in definition order. This is how CPython implements
fast locals — an array indexed by position in co_varnames,
avoiding dictionary lookups for local variables. The C struct member is
frame->localsplus[i], and the opcode
LOAD_FAST i simply indexes into that array — it's
O(1) with no hashing overhead.
The co_flags bitmask encodes important properties. The most common
flags are: CO_OPTIMIZED (0x0001) — locals use fast-locals array, not
a dict; CO_NEWLOCALS (0x0002) — a new locals dict is created;
CO_VARARGS (0x0004) — accepts *args;
CO_VARKEYWORDS (0x0008) — accepts **kwargs;
CO_GENERATOR (0x0020) — this is a generator function;
CO_COROUTINE (0x0100) — this is an async def function.
You can inspect them with the inspect module constants:
inspect.CO_GENERATOR, etc.
3 · Bytecode & the dis Module
Python bytecode is a compact sequence of 2-byte instructions:
one byte for the opcode and one byte for its argument. For
arguments larger than 255, the EXTENDED_ARG prefix opcode
shifts the value up by one byte — allowing 16-, 24-, or 32-bit arguments.
The dis standard-library module decodes raw bytecode into a
human-readable format and provides structured access via
dis.Instruction named-tuples.
import dis
def process(items, threshold=10):
result = []
for item in items:
if item > threshold:
result.append(item * 2)
return result
# Human-readable disassembly
dis.dis(process)
# Instruction objects (Python 3.12+)
for instr in dis.get_instructions(process):
print(f"{instr.offset:4d} {instr.opname:25s} {instr.argval!r}")
# Raw bytecode bytes (Python ≤ 3.10 used co_code; 3.11+ use co_code still but layout changed)
print(process.__code__.co_code.hex())bytecode_demo.py
The disassembly output shows one instruction per line with four columns:
the source line number (for the first instruction on that line),
the byte offset in the bytecode string,
the opname (symbolic name of the opcode), and the
argument (raw integer) plus its human-readable interpretation
in parentheses — for example, LOAD_FAST 0 (items) tells you that
argument 0 resolves to the local variable named items.
Essential Opcodes
| Opcode | Stack effect | Description |
|---|---|---|
LOAD_FAST | +1 | Push local variable (by co_varnames index) |
STORE_FAST | -1 | Pop → store into local variable |
LOAD_GLOBAL | +1 | Push global (or builtin) variable |
LOAD_CONST | +1 | Push constant from co_consts |
LOAD_ATTR | 0 (replaces TOS) | Attribute lookup: TOS = TOS.attr |
CALL | varies | Call a callable; pops args |
RETURN_VALUE | -1 | Return TOS to caller |
POP_JUMP_IF_FALSE | -1 | Pop TOS; jump if falsy |
FOR_ITER | 0/+1 | Advance iterator; jump if exhausted |
BUILD_LIST | -N+1 | Pop N items, push list |
BINARY_OP | -1 | Binary operation on TOS and TOS1 |
GET_ITER | 0 | TOS = iter(TOS) |
RESUME | 0 | Entry point marker (Python 3.11+) |
Reading a stack trace: TOS means "top of stack", TOS1 is the item
below TOS, and so on. Each instruction pops its inputs and pushes its output.
The net stack depth change is the "stack effect" column above. The CPython compiler
computes the maximum stack depth statically (stored in co_stacksize)
and pre-allocates the C array — there is no dynamic resizing during execution.
LOAD_ATTR are initially executed as generic instructions.
After being observed several times with the same type signature, CPython
specialises them in-place — replacing LOAD_ATTR with
LOAD_ATTR_MODULE, LOAD_ATTR_SLOT,
LOAD_ATTR_INSTANCE_VALUE, etc. These specialised variants skip
the full attribute-lookup machinery and go directly to the fast path, delivering
meaningful speedups without a full JIT compilation step.
4 · Frame Objects
Every function call creates a frame object
(PyFrameObject in C; exposed as types.FrameType in
Python). The frame holds the execution context for one invocation of a code object:
the current instruction pointer, the value stack, local variables (fast-locals
array), a reference to the enclosing frame, and a pointer to the globals dict.
Frames are chained — f_back links each frame to its caller — forming
the call stack.
import sys
import types
def inner():
frame = sys._getframe() # current frame
print("=== inner() frame ===")
print(f" f_code.co_name : {frame.f_code.co_name}")
print(f" f_lineno : {frame.f_lineno}")
print(f" f_locals : {frame.f_locals}")
print(f" f_back : {frame.f_back.f_code.co_name}") # caller
def outer():
x = 42
inner()
outer()frame_introspection.pyFrame Attributes
| Attribute | Description |
|---|---|
f_code | The code object being executed |
f_locals | Dict of local variables (snapshot; writing to it has caveats) |
f_globals | The module's global namespace dict |
f_builtins | The builtins namespace dict |
f_back | The calling frame (None for the topmost frame) |
f_lineno | Current line number being executed |
f_lasti | Index of last attempted instruction in bytecode |
f_trace | Callable invoked by the trace hook (used by debuggers/coverage) |
Walking the Full Call Stack
import sys
def walk_stack():
frame = sys._getframe()
depth = 0
while frame:
print(f"{' ' * depth}{frame.f_code.co_name}() "
f"at {frame.f_code.co_filename}:{frame.f_lineno}")
frame = frame.f_back
depth += 1
def c():
walk_stack()
def b():
c()
def a():
b()
a()
# Output (innermost first):
# walk_stack() at frame_walk.py:4
# c() at frame_walk.py:13
# b() at frame_walk.py:16
# a() at frame_walk.py:19
# <module>() at frame_walk.py:21frame_walk.py
The inspect module wraps this pattern in
inspect.stack() / inspect.currentframe(), returning
richer FrameInfo named-tuples that include the source line text.
Many logging and debugging libraries use this to capture the call site automatically.
frame.f_locals returns a
snapshot dict built on demand — it does not reflect live
local variable writes for optimised (non-module) frames. CPython stores locals in
the fast-locals C array; reading f_locals triggers
PyFrame_FastToLocals which copies the C array into a Python dict.
Writing to the dict does not propagate back unless
PyFrame_LocalsToFast is called. Libraries like
pytest use frame introspection for assertion rewriting; debuggers
like pdb and pydevd use f_trace to step
through code one line at a time.
5 · The Eval Loop (ceval.c)
The heart of CPython is _PyEval_EvalFrameDefault in
Python/ceval.c — a massive C function (thousands of lines) that
implements a dispatch loop over bytecode instructions. Understanding it
conceptually is the key to understanding CPython's performance characteristics.
Conceptual Pseudocode
_PyEval_EvalFrameDefault(frame):
while True:
opcode, arg = next_instruction(frame)
switch(opcode):
case LOAD_FAST:
push(frame.fastlocals[arg])
case STORE_FAST:
frame.fastlocals[arg] = pop()
case BINARY_OP:
right = pop()
left = pop()
push(left op right) # dispatches to type's nb_add etc.
case CALL:
args = pop_n(arg)
func = pop()
new_frame = make_frame(func, args)
result = _PyEval_EvalFrameDefault(new_frame) # recursive
push(result)
case RETURN_VALUE:
return pop()
case POP_JUMP_IF_FALSE:
cond = pop()
if not cond: frame.pc = arg
...
The Value Stack
The value stack is a fixed-size C array of PyObject* pointers,
pre-allocated when the frame is created with size co_stacksize.
A stack_pointer register tracks the current top. Pushing an object
means *stack_pointer++ = obj; popping means
obj = *--stack_pointer. Every intermediate result of an expression —
loaded constants, attribute lookup results, return values — lives on this stack
between instructions.
Dispatch Mechanisms
CPython uses different strategies to dispatch the switch(opcode)
across versions:
-
Pre-3.11 — computed goto: On GCC/Clang, CPython uses a
goto *dispatch_table[opcode]pattern (a GCC extension). This is significantly faster than aswitchbecause the CPU branch predictor can learn per-opcode targets rather than predicting a single switch expression. -
3.11+ — Specialising Adaptive Interpreter: The eval loop now
self-patches hot instructions in-place. After an instruction executes a few times
and its operand types are stable, CPython replaces the generic opcode byte with
a specialised variant (e.g.
LOAD_ATTR_SLOT). If the type later changes, it de-specialises back. This is a form of inline caching with no separate JIT compilation phase. - 3.13+ — Free-threaded (no-GIL) mode: With the GIL removed, object access requires per-object locking or biased reference counting. The eval loop adds memory barriers around certain operations. This is still experimental and opt-in as of 3.13.
PyObject* from the stack, checking its
type via ob_type->tp_as_number->nb_add, incrementing and
decrementing reference counts (Py_INCREF/Py_DECREF),
and potentially triggering garbage collection. A simple a + b in
Python generates multiple C function calls and memory accesses that a C compiler
would reduce to a single ADD instruction.
Tools that circumvent the eval loop for hot paths — PyPy (tracing JIT), Cython (transpile to C), Numba (
@jit with LLVM backend) — see 10–1000×
speedups for numeric work precisely because they eliminate this per-opcode
overhead.
6 · Trace Hooks & sys.settrace
CPython's eval loop has a built-in tracing hook mechanism that
powers all Python-level debuggers, coverage tools, and profilers. When a trace
function is installed via sys.settrace(fn), the eval loop calls
fn(frame, event, arg) at precise moments during execution. The
callback's return value becomes the per-frame trace function — returning
None disables tracing for that frame; returning itself continues it.
import sys
call_log = []
def tracer(frame, event, arg):
"""Called by the eval loop before each line/call/return/exception."""
if event == "call":
call_log.append(f"CALL {frame.f_code.co_name}() at line {frame.f_lineno}")
elif event == "line":
call_log.append(f"LINE {frame.f_code.co_name}:{frame.f_lineno}")
elif event == "return":
call_log.append(f"RETURN {frame.f_code.co_name}() → {arg!r}")
return tracer # must return itself to continue tracing inner frames
def add(a, b):
result = a + b
return result
sys.settrace(tracer)
add(3, 4)
sys.settrace(None)
for entry in call_log:
print(entry)
# Typical output:
# CALL add() at line 12
# LINE add:13
# LINE add:14
# RETURN add() → 7settrace_demo.pyTrace Events
| Event | When fired | arg value |
|---|---|---|
"call" | A function is called; before any bytecode executes | None |
"line" | About to execute a new source line | None |
"return" | Function is about to return | The return value |
"exception" | An exception has been raised in this frame | (exc_type, exc_value, traceback) |
"opcode" | Before each opcode (must enable frame.f_trace_opcodes = True) | None |
sys.setprofile — Lower-overhead Alternative
sys.setprofile(fn) is similar but fires only on
"call" and "return" events — no per-line overhead.
It is used by the cProfile module. Because there is no
"line" event, it cannot track which specific line executed, but the
reduced call frequency makes it far less intrusive for profiling.
import sys, time
profile_data = {}
def profiler(frame, event, arg):
name = frame.f_code.co_name
if event == "call":
profile_data[name] = {"start": time.perf_counter(), "calls": 0}
elif event == "return":
if name in profile_data:
elapsed = time.perf_counter() - profile_data[name]["start"]
profile_data[name]["elapsed"] = elapsed
profile_data[name]["calls"] += 1
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
sys.setprofile(profiler)
fibonacci(20)
sys.setprofile(None)
for fn, data in sorted(profile_data.items(), key=lambda x: -x[1].get("elapsed", 0)):
print(f"{fn:20s} calls={data['calls']} elapsed={data.get('elapsed', 0):.6f}s")setprofile_demo.pysys.settrace has significant performance overhead —
every source line triggers a Python-level function call from C. Coverage.py
minimises this by installing a trace function that simply sets a boolean bit per
line. For production profiling, prefer statistical profilers like
py-spy, which attach to a running process from outside and sample
the C-level call stack at configurable intervals (e.g. 100 Hz) with near-zero
overhead and no code instrumentation. austin and
Scalene are similarly non-intrusive.
7 · Bytecode Manipulation & compile()
Because code objects are first-class Python values and compile() is
a built-in, advanced Python tooling can generate, inspect, and modify code at
runtime. This power underpins import hooks, template engines, test frameworks
(pytest's assertion rewriting), and domain-specific language compilers.
Compiling and Executing Dynamic Code
import ast
import types
import dis
# ── Compile from string with custom filename ──
src = """
x = 10
y = 20
print(x + y)
"""
code = compile(src, "<dynamic>", "exec")
exec(code) # 30
# ── Compile a function and inspect it ──
func_src = "lambda a, b: a ** b + 1"
code_obj = compile(func_src, "<lambda>", "eval")
fn = eval(code_obj)
print(fn(2, 10)) # 1025compile_dynamic.pyPatching a Code Object with .replace()
Since Python 3.8, code.replace(**kwargs) returns a new code object
with specific fields overwritten. This is the approved way to surgically modify
a code object without reinventing the marshalling format.
import types
def original():
x = 100
return x
print(original()) # 100
# Replace the constant 100 with 999 in the constants pool
new_consts = tuple(
999 if c == 100 else c
for c in original.__code__.co_consts
)
new_code = original.__code__.replace(co_consts=new_consts)
# Wrap the patched code object in a new function object
patched = types.FunctionType(
new_code,
original.__globals__,
original.__name__,
)
print(patched()) # 999patch_code_object.pyAST-Level Compilation
Working at the AST level is more portable than manipulating raw bytecode because
the AST is defined by the language grammar (stable across minor versions), whereas
bytecode layout changed in 3.6, 3.8, 3.10, 3.11, and 3.12.
ast.fix_missing_locations() fills in line-number and column-offset
fields required by the compiler.
import ast
tree = ast.parse("result = x * 2 + 1")
# Wrap the expression in a function definition
func_def = ast.Module(body=[
ast.FunctionDef(
name="compute",
args=ast.arguments(
posonlyargs=[], args=[ast.arg(arg="x")],
vararg=None, kwonlyargs=[], kw_defaults=[],
kwarg=None, defaults=[],
),
body=tree.body + [
ast.Return(value=ast.Name(id="result", ctx=ast.Load()))
],
decorator_list=[],
)
], type_ignores=[])
ast.fix_missing_locations(tree)
ast.fix_missing_locations(func_def)
code = compile(func_def, "<ast>", "exec")
ns = {}
exec(code, ns)
print(ns["compute"](5)) # 11ast_compile.pyPractical Use Cases
-
pytest assertion rewriting: pytest installs an import hook that
rewrites the AST of test modules before compilation, transforming
assert a == binto code that records both operands for the failure message. - Numba / Cython: Compile Python functions to native machine code by walking the AST or bytecode and emitting LLVM IR or C.
-
Security sandboxing: Restrict allowed opcodes or AST node types
(e.g. ban
Importnodes) before executing untrusted code. -
Decorators with
@functools.wraps: Preserve the original function's__wrapped__,__name__, and__code__for introspection tools.
LOAD_CONST changes),
3.10 (jump offsets), 3.11 (adaptive specialisation, exception table), and
3.12 (further frame restructuring). Code that manipulates co_code
bytes directly will break across versions. Prefer
AST-level transformations which go through the compiler and
are inherently portable. Libraries like executing and
codetransformer demonstrate safe, version-aware approaches.
Closures, Cell Objects & Free Variables
When a nested function references a variable from an enclosing scope, CPython creates a cell object — a mutable box shared between the outer and inner function's local variable arrays.
import dis
def make_counter(start=0):
count = start # becomes a cell variable
def increment(step=1):
nonlocal count # marks `count` as a free variable in increment
count += step
return count
return increment
counter = make_counter(10)
print(counter()) # 11
print(counter(5)) # 16
# Inspect the cell machinery
print(make_counter.__code__.co_cellvars) # ('count',)
print(counter.__code__.co_freevars) # ('count',)
print(counter.__closure__) # (| ,)
print(counter.__closure__[0].cell_contents) # 16
# Disassemble inner to see LOAD_DEREF / STORE_DEREF opcodes
dis.dis(counter) |
closures.py
LOAD_DEREF / STORE_DEREF access the shared cell object
rather than the fast-locals array. This is why nonlocal works across
multiple calls — the cell is allocated on the heap, not the C stack.
# Classic "late binding" gotcha — all closures share the same cell
fns = [lambda: i for i in range(5)]
print([f() for f in fns]) # [4, 4, 4, 4, 4] — all see i=4 at call time
# Fix: capture the value at definition time with a default argument
fns_fixed = [lambda i=i: i for i in range(5)]
print([f() for f in fns_fixed]) # [0, 1, 2, 3, 4]
late_binding.py
i
is a single cell shared by all five lambdas; by the time any of them is called, the
loop has finished and i == 4.
Generator Frames & Coroutine State
Generators and coroutines are suspended frames — when you call next()
or await, CPython resumes the frame from where it was paused.
import dis, sys
def countdown(n):
while n > 0:
yield n
n -= 1
gen = countdown(3)
# The generator object holds its frame
print(type(gen.gi_frame)) #
print(gen.gi_frame.f_lasti) # -1 (not started yet)
print(gen.gi_code.co_name) # 'countdown'
next(gen) # runs until first yield
print(gen.gi_frame.f_lasti) # offset of the YIELD_VALUE instruction
print(gen.gi_frame.f_locals) # {'n': 3} — local state preserved
# Disassemble — look for YIELD_VALUE opcode
dis.dis(countdown)
# Coroutine state inspection
import asyncio
async def fetcher(url):
await asyncio.sleep(0)
return url
coro = fetcher("https://example.com")
print(coro.cr_frame) # suspended frame
print(coro.cr_code.co_name) # 'fetcher'
print(coro.cr_await) # None (not yet running)
coro.close() # clean up without running
generator_frames.py
next() is called. This is why generators are memory-efficient
compared to building a full list — only one frame is live at a time, not all N items.
Practical Tooling: coverage.py, pdb & py-spy
Understanding the CPython internals makes the behaviour of essential development tools obvious rather than magical.
How coverage.py works
import sys
executed_lines: dict[str, set[int]] = {}
def coverage_tracer(frame, event, arg):
if event == "line":
filename = frame.f_code.co_filename
lineno = frame.f_lineno
executed_lines.setdefault(filename, set()).add(lineno)
return coverage_tracer
sys.settrace(coverage_tracer)
# ── Code under measurement ──
def add(a, b):
return a + b
def greet(name):
if name:
return f"Hello, {name}"
return "Hello, stranger"
add(1, 2)
greet("Alice")
# greet("") not called — else branch not covered
sys.settrace(None)
for filename, lines in executed_lines.items():
print(f"{filename}: lines {sorted(lines)}")
mini_coverage.py
How pdb works
pdb uses sys.settrace with a line-event handler.
When the trace function is called, it checks whether the current
(filename, lineno) is a breakpoint — if so, it drops into an interactive
REPL. It can modify frame.f_locals (with limitations) and
frame.f_lineno to implement jump.
py-spy: Statistical profiling without sys.settrace
# py-spy attaches to a running Python process from OUTSIDE
# No code changes needed, no settrace overhead
pip install py-spy
# Sample a running process (replace PID)
py-spy top --pid 12345
# Record a flamegraph
py-spy record -o profile.svg --pid 12345
# Profile a script directly
py-spy record -o profile.svg -- python my_script.py
terminal
py-spy reads the target process's memory directly (using platform APIs
like ptrace on Linux) to walk the frame linked list and sample
f_code.co_filename + f_lineno at regular intervals.
Because it never modifies the Python process, there is zero interpreter overhead —
it is safe to run against production processes.
Best Practices
- Use
dis.dis()to diagnose performance surprises — unexpectedLOAD_GLOBALinside a tight loop (instead ofLOAD_FAST) is a common cause of slowness. Bind globals to locals before the loop. - Prefer AST manipulation over bytecode manipulation — AST transforms survive Python version upgrades; raw bytecode does not. Libraries like
ast.NodeTransformerare the right abstraction level. - Use
sys.setprofilenotsys.settracefor profiling —setprofileonly fires on function calls/returns (no per-line overhead), making it ~10× faster for profiling. - Understand the closure late-binding trap — when creating closures in a loop, always capture the current value with a default-argument trick or
functools.partial. - Use
code.replace()for surgical bytecode changes (Python 3.8+) rather than constructing a newCodeTypefrom scratch — thereplace()method handles all the fields you don't want to change. - For production profiling, use
py-spyorPyroscope— zero-overhead statistical profilers that read frame state from outside the process. - Remember that
f_localsis a snapshot — it does not reflect live writes to optimised frames; use it for inspection, not mutation. - Know which CPython version changed the bytecode — 3.6 (wordcode), 3.10 (exception tables), 3.11 (frame revamp, specialising interpreter), 3.12 (inlined comprehension frames), 3.13 (free-threaded mode).
Exercises
Exercise 1 — Bytecode Inspector CLI
Build a command-line tool that takes a Python source file and prints a human-readable bytecode report for every function defined at module level:
- Use
ast.parse()to find allFunctionDefnodes. - Use
compile()+dis.get_instructions()to extract the instructions for each function. - For each function, print: name, arg count, local variable count, stack size, and a table of opcodes with their offsets and arguments.
- Highlight any
LOAD_GLOBALinside a loop body (hint: check if the offset falls between aGET_ITERand the matchingFOR_ITERjump target) — these are potential optimisation points.
💡 Hint
import ast, dis, sys
def inspect_file(path: str) -> None:
source = open(path).read()
tree = ast.parse(source, path)
code = compile(source, path, "exec")
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
# Compile just the function by wrapping in exec-mode code
fn_source = ast.get_source_segment(source, node) or ""
try:
fn_code = compile(fn_source, path, "exec")
# Find nested code object for the function
for const in fn_code.co_consts:
if hasattr(const, "co_name") and const.co_name == node.name:
print(f"\n{'='*50}")
print(f"Function: {node.name} args={const.co_argcount} "
f"locals={const.co_nlocals} stack={const.co_stacksize}")
print(f"{'='*50}")
dis.dis(const)
except SyntaxError:
pass
if __name__ == "__main__":
inspect_file(sys.argv[1])
Exercise 2 — Mini Call Tracer
Implement a @trace decorator that uses sys.settrace
to log every line executed inside the decorated function, showing the
line number, source text, and local variable state at that point:
- The decorator should install a trace, call the function, then uninstall.
- For each
lineevent, read the source line from the file usinglinecache.getline(). - Print:
[line N] source_text | locals: {k: v} - Test it on a small function with a loop and a conditional.
- Ensure the trace is always uninstalled in a
finallyblock.
💡 Hint
import sys, linecache, functools
def trace(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
def local_tracer(frame, event, arg):
if event == "line" and frame.f_code is fn.__code__:
src = linecache.getline(frame.f_code.co_filename, frame.f_lineno).rstrip()
print(f"[line {frame.f_lineno:3d}] {src} | locals={frame.f_locals}")
return local_tracer
sys.settrace(local_tracer)
try:
return fn(*args, **kwargs)
finally:
sys.settrace(None)
return wrapper
@trace
def fizzbuzz(n):
for i in range(1, n + 1):
if i % 15 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
fizzbuzz(5)
Exercise 3 — Constant Folding via Code Object Patching
Write a function patch_constants(fn, mapping) that replaces
constants in a function's code object without touching any other attributes:
- Use
code.replace(co_consts=new_consts)(Python 3.8+). - The
mappingis a dict of{old_value: new_value}. - Return a new function with the patched code object using
types.FunctionType. - Recursively patch nested code objects (e.g. lambda constants inside a function).
- Test: patch
def tax_rate(): return 0.20→return 0.25. - Verify with
dis.dis()that theLOAD_CONSTnow shows the new value.
💡 Hint
import types, dis
def patch_constants(fn, mapping: dict):
def patch_code(code):
new_consts = tuple(
patch_code(c) if isinstance(c, types.CodeType)
else mapping.get(c, c)
for c in code.co_consts
)
return code.replace(co_consts=new_consts)
new_code = patch_code(fn.__code__)
return types.FunctionType(
new_code, fn.__globals__, fn.__name__,
fn.__defaults__, fn.__closure__,
)
def tax_rate():
return 0.20
patched = patch_constants(tax_rate, {0.20: 0.25})
print(tax_rate()) # 0.20
print(patched()) # 0.25
dis.dis(patched) # LOAD_CONST 0.25