Skip to content

Runtime Internals: Reading Python From the Inside

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:

  1. What is this name actually bound to, and is it shared? (the object model — id, is, refcounts)
  2. When does this object actually go away, and can it leak? (memory and the garbage collector)
  3. What does this line actually execute, and in what order? (bytecode, frames, the interpreter loop)
  4. Can two threads touch this at once, and what happens if they do? (the GIL and free-threading)

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"]
Source to running program: the four stages this module covers
  • object-model.mdx covers the right-hand box: what a Python object actually is in memory, how names bind to them, and how attribute lookup decides what obj.attr returns.
  • memory-and-gc.mdx covers 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.mdx covers 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.mdx covers 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 dis
import 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
3

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

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.

What you getBenefitCost
Learning the object model explicitlyis vs ==, __slots__, descriptor precedence stop being surprisesOne more layer to learn before “just write the code”
Understanding the GC and refcountingYou can spot a leaking cycle or an lru_cache leak before it shipsExtra vocabulary (generations, weakrefs) for a cost you rarely pay by hand
Reading bytecodeYou can settle “which is faster” arguments with dis, not guessesdis output changes across Python versions — it is not a stable API
Knowing the GIL mechanismYou 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
  • “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.mdx lesson shows real 3.14 opcode names (LOAD_FAST_BORROW, RESUME_CHECK) that did not exist a few versions ago. Use dis to 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.mdx plus the async module’s gil-threads lesson both cover exactly why.
  • “Garbage collection means I never have to think about memory in Python.” Reference cycles, module-level caches, and functools.lru_cache on a bound method all leak in ordinary, idiomatic-looking Python — memory-and-gc.mdx reproduces all three.

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_cache or @functools.cache? The cache holds a reference to self, keeping every instance alive for the life of the program — memory-and-gc.mdx reproduces 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.

What are the four stages this module's Mermaid diagram traces from a .py file to a running program?
Why does 1 + 2 show up as LOAD_SMALL_INT 3 in the disassembly instead of loading 1 and 2 separately and adding them?
Which of these is something the object model (not the GC, not the interpreter loop, not the GIL) is directly responsible for explaining?
What is the practical consequence of decorating an instance method this way?
import functools

class Report:
    def __init__(self, name):
        self.name = name

    @functools.lru_cache(maxsize=None)
    def render(self):
        return self.name.upper()
A comment on a shared global counter says count += 1 # atomic, no lock needed. Which lesson in this module would you check to verify or refute that claim?