🟠 Python Internals

CPython Internals: Bytecode, Frames & the Eval Loop

📖 Lesson 43 ⏱ 60 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand CPython's compilation pipeline: source → AST → bytecode → execution
  • Inspect bytecode with dis module 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 code objects: inspect co_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.py
Concept: Everything in Python goes through this pipeline. When you import 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.py

Key co_* Attributes

Attribute Type Description
co_namestrFunction/class/module name
co_qualnamestrFully qualified name (Python 3.11+)
co_filenamestrSource file
co_firstlinenointFirst line of the function
co_argcountintNumber of positional args (not *args/**kwargs)
co_varnamestuple[str]All local variable names (args first, then locals)
co_conststupleConstants pool (literals, nested code objects)
co_namestuple[str]Global names referenced
co_cellvarstuple[str]Locals captured by nested functions
co_freevarstuple[str]Names captured from enclosing scope
co_codebytesRaw bytecode (Python ≤ 3.10); use co_code or co_consts
co_stacksizeintMax operand stack depth needed
co_flagsintBitmask of CO_* flags
Tip: 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+1Push local variable (by co_varnames index)
STORE_FAST-1Pop → store into local variable
LOAD_GLOBAL+1Push global (or builtin) variable
LOAD_CONST+1Push constant from co_consts
LOAD_ATTR0 (replaces TOS)Attribute lookup: TOS = TOS.attr
CALLvariesCall a callable; pops args
RETURN_VALUE-1Return TOS to caller
POP_JUMP_IF_FALSE-1Pop TOS; jump if falsy
FOR_ITER0/+1Advance iterator; jump if exhausted
BUILD_LIST-N+1Pop N items, push list
BINARY_OP-1Binary operation on TOS and TOS1
GET_ITER0TOS = iter(TOS)
RESUME0Entry 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.

Concept — Specialising Adaptive Interpreter (Python 3.11+): The bytecode was redesigned for CPython 3.11's specialising adaptive interpreter. "Hot" opcodes like 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.py

Frame Attributes

Attribute Description
f_codeThe code object being executed
f_localsDict of local variables (snapshot; writing to it has caveats)
f_globalsThe module's global namespace dict
f_builtinsThe builtins namespace dict
f_backThe calling frame (None for the topmost frame)
f_linenoCurrent line number being executed
f_lastiIndex of last attempted instruction in bytecode
f_traceCallable 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:21
frame_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.

Warning: 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 a switch because 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.
Concept — Why is Python "slow"? The eval loop is inherently expensive compared to native code. Every single Python operation involves: loading a 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() → 7
settrace_demo.py

Trace Events

EventWhen firedarg value
"call"A function is called; before any bytecode executesNone
"line"About to execute a new source lineNone
"return"Function is about to returnThe 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.py
Warning — Performance overhead: sys.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))   # 1025
compile_dynamic.py

Patching 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())   # 999
patch_code_object.py

AST-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))   # 11
ast_compile.py

Practical Use Cases

  • pytest assertion rewriting: pytest installs an import hook that rewrites the AST of test modules before compilation, transforming assert a == b into 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 Import nodes) before executing untrusted code.
  • Decorators with @functools.wraps: Preserve the original function's __wrapped__, __name__, and __code__ for introspection tools.
Warning — Version fragility: Bytecode manipulation is CPython-version-specific. The instruction format changed significantly in 3.6 (word-code), 3.8 (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
The late-binding gotcha is one of the most common Python interview questions. It is a direct consequence of how closures work: the cell object stores a reference to the variable, not its value at closure-creation time. The loop variable 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
When a generator yields, CPython saves the entire frame state (locals, stack pointer, instruction pointer) inside the generator object. The frame is re-attached to the eval loop when 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 — unexpected LOAD_GLOBAL inside a tight loop (instead of LOAD_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.NodeTransformer are the right abstraction level.
  • Use sys.setprofile not sys.settrace for profilingsetprofile only 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 new CodeType from scratch — the replace() method handles all the fields you don't want to change.
  • For production profiling, use py-spy or Pyroscope — zero-overhead statistical profilers that read frame state from outside the process.
  • Remember that f_locals is 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 all FunctionDef nodes.
  • 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_GLOBAL inside a loop body (hint: check if the offset falls between a GET_ITER and the matching FOR_ITER jump 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 line event, read the source line from the file using linecache.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 finally block.
💡 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 mapping is 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.20return 0.25.
  • Verify with dis.dis() that the LOAD_CONST now 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