Runtime Internals: Reading Python From the Inside
Why a reader needs the interpreter model
Section titled “Why a reader needs the interpreter model”Every earlier module in this course taught you to write Python: syntax, data structures, classes, functions, typing, async. None of it required knowing how any of it actually runs. You can write a working class, a correct decorator, a for loop over a dict, without ever asking what object self actually points to, what a for loop compiles down to, or why two threads racing on count += 1 can lose an update.
That gap is exactly where AI-generated Python fails most often — and where a reviewer who has never looked at the machinery underneath fails to catch it. A generated snippet can look idiomatic, pass a quick read, and still leak memory through a reference cycle, corrupt a shared counter under threads, or silently do the wrong thing because of how attribute lookup actually works. None of those are syntax errors. All of them are runtime facts.
This module has one job: give you the four pieces of runtime knowledge that let you look at someone else’s Python source — not run it, not profile it, just read it — and answer four questions with confidence:
- What is this name actually bound to, and is it shared? (the object model —
id,is, refcounts) - When does this object actually go away, and can it leak? (memory and the garbage collector)
- What does this line actually execute, and in what order? (bytecode, frames, the interpreter loop)
- Can two threads touch this at once, and what happens if they do? (the GIL and free-threading)
From source text to running objects
Section titled “From source text to running objects”Every .py file you have written in this course has gone through the same four stages before a single line of your logic executes. This module is organized around them, in order:
flowchart LR SRC["Source text (.py file)"] --> COMP["Compiler (parse + compile)"] COMP --> CODE["Code object (co_consts, co_names, co_varnames, bytecode)"] CODE --> LOOP["Eval loop (frames, value stack, one opcode at a time)"] LOOP --> OBJ["Objects (PyObject: refcount + type heap-allocated, GC-tracked)"] OBJ -. mutated/read by .-> LOOP LOOP -. GIL serializes access .-> THREADS["Threads"]
object-model.mdxcovers the right-hand box: what a Python object actually is in memory, how names bind to them, and how attribute lookup decides whatobj.attrreturns.memory-and-gc.mdxcovers what happens to those objects over time: when refcounting alone frees them, when the cycle collector has to step in, and how they leak anyway.bytecode-and-interpreter.mdxcovers the middle two boxes: how source becomes a code object, how the eval loop executes it frame by frame, and how the interpreter optimizes itself as it runs.gil-and-free-threading.mdxcovers the dashed arrow: what serializes access to those objects when more than one thread is involved, and what changes when it doesn’t.
A runnable preview: all four stages, one demo
Section titled “A runnable preview: all four stages, one demo”import disimport sys
source = "y = 1 + 2\nprint(y)\n"code_obj = compile(source, "<demo>", "exec")print("co_consts:", code_obj.co_consts)print("co_names:", code_obj.co_names)
print("--- bytecode ---")dis.dis(code_obj)
print("--- running it ---")exec(code_obj)Real output, python3.14:
co_consts: (1, None)co_names: ('y', 'print')--- bytecode --- 0 RESUME 0
1 LOAD_SMALL_INT 3 STORE_NAME 0 (y)
2 LOAD_NAME 1 (print) PUSH_NULL LOAD_NAME 0 (y) CALL 1 POP_TOP LOAD_CONST 1 (None) RETURN_VALUE
3Notice 1 + 2 never appears as 1, 2, and an add instruction — the compiler constant-folds it to 3 at compile time, and 3 is small enough that it gets its own dedicated LOAD_SMALL_INT opcode instead of a co_consts lookup (co_consts here only holds 1 — a leftover from parsing — and None, the implicit return of a module). That is the compiler stage. dis.dis shows you the code-object stage. exec runs it through the eval loop. The 3 it prints is a live Python object, immortal-cached (see object-model.mdx) the moment it is created. Four stages, one paragraph of code — the rest of this module goes deep on each.
import dis
source = "y = 1 + 2\nprint(y)\n"code_obj = compile(source, "<demo>", "exec")print("co_consts:", code_obj.co_consts)print("co_names:", code_obj.co_names)print("--- bytecode ---")dis.dis(code_obj)print("--- running it ---")exec(code_obj)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”A running Python program is a compiler’s output (a tree of code objects) being walked by one C loop, one bytecode instruction at a time, manipulating a heap of reference-counted objects. Every mysterious behavior in this course so far — why is sometimes lies, why a cycle doesn’t free itself, why a for loop is faster than an equivalent while, why two threads doing “nothing but math” still don’t get a 2x speedup — has a mechanical answer at one of these four stages. You do not need to memorize CPython’s C source. You need the four load-bearing facts this module verifies, so that when you read someone else’s code, you know which of the four stages to suspect.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Learning the object model explicitly | is vs ==, __slots__, descriptor precedence stop being surprises | One more layer to learn before “just write the code” |
| Understanding the GC and refcounting | You can spot a leaking cycle or an lru_cache leak before it ships | Extra vocabulary (generations, weakrefs) for a cost you rarely pay by hand |
| Reading bytecode | You can settle “which is faster” arguments with dis, not guesses | dis output changes across Python versions — it is not a stable API |
| Knowing the GIL mechanism | You know when threads help (I/O) and when they cannot (CPU-bound, GIL build) | Free-threaded Python is real in 3.14 but still young — tooling and libraries are catching up |
Common Misconceptions
Section titled “Common Misconceptions”- “I don’t need to know how the interpreter works to write good Python.” You can write working Python without it. You cannot reliably read someone else’s Python — especially AI-generated Python, which is fluent but unaccountable — without knowing where the four classic failure modes (identity bugs, cycles/leaks, ordering bugs, race conditions) actually come from.
- “CPython bytecode is a stable, documented API.” It changes every minor version — this module’s
bytecode-and-interpreter.mdxlesson shows real 3.14 opcode names (LOAD_FAST_BORROW,RESUME_CHECK) that did not exist a few versions ago. Usedisto understand, never to depend on exact opcodes in production code. - “The GIL means threads are useless in Python.” It means threads are useless for CPU-bound work on a GIL build — I/O-bound threading has always worked, and
gil-and-free-threading.mdxplus the async module’sgil-threadslesson both cover exactly why. - “Garbage collection means I never have to think about memory in Python.” Reference cycles, module-level caches, and
functools.lru_cacheon a bound method all leak in ordinary, idiomatic-looking Python —memory-and-gc.mdxreproduces all three.
Reading AI-generated code
Section titled “Reading AI-generated code”A model that generates fluent Python has no mechanism for knowing whether the code it produced will leak, race, or silently do the wrong thing at runtime — it pattern-matches on what looks correct. Before trusting generated code that touches shared state, caching, or long-lived objects, ask:
- Does this create a reference cycle (two objects pointing at each other, or a parent holding a child that holds a callback back to the parent) with no plan for it? Reference counting alone will never free it.
- Does this decorate a method with
@functools.lru_cacheor@functools.cache? The cache holds a reference toself, keeping every instance alive for the life of the program —memory-and-gc.mdxreproduces this exactly. - Does this share a plain variable, list, or dict across threads with a read-then-write pattern (
counter += 1,if key not in d: d[key] = ...)? Multiple bytecode instructions execute between the read and the write; a thread switch in between loses an update. - Does a comment claim something is “atomic” or “thread-safe” without naming which single bytecode instruction (or C-level lock) makes it so?
🐛 Spot the bug
import functools
class ReportBuilder: def __init__(self, name): self.name = name
@functools.lru_cache(maxsize=None) def render(self, section): return f"{self.name}: {section}"
for i in range(10_000): builder = ReportBuilder(f"report-{i}") builder.render("summary")Show the answer
Each loop iteration creates a new ReportBuilder and calls its lru_cache-decorated render. lru_cache keys its cache on the arguments passed to the call — and for a bound method, the first argument is self. Every distinct builder instance becomes a permanent cache key, so functools.lru_cache holds a strong reference to every one of the 10,000 ReportBuilder objects for the rest of the program’s life, even though nothing else in the program still needs them. render’s own local work (a single f-string) never benefited from caching in the first place, since section is different on every call anyway relative to a unique self. The fix, covered in memory-and-gc.mdx, is to never put @lru_cache directly on an instance method; cache a module-level or staticmethod function that takes only the values that actually repeat.