Production Runtime Case Study: Python and CPython
When you run a Python file, CPython parses it, compiles it into code objects containing bytecode, and runs that bytecode in a big interpreter loop written in C.
Learning Objectives
Separate Python the language from CPython the implementation; follow source through parsing, AST, compilation to code objects and bytecode.
Use dis; explain code and function objects, frames, namespaces, local/global/free lookup, closures and cells, reference counting and cyclic GC, imports and sys.modules, virtual environments, exceptions and tracebacks.
Explain generators and coroutines as suspended frames, the async event loop versus the interpreter, C extensions and the C API, the GIL and free-threaded builds, profiling tools, start-up and import cost, packaging, and why CPython bytecode is not a stable format.
Plain-Language Explanation
When you run a Python file, CPython parses it, compiles it into code objects containing bytecode, and runs that bytecode in a big interpreter loop written in C. Variables live in dictionaries (globals) or fast arrays (locals); objects are freed when nothing refers to them; and since Python 3.11 the interpreter specialises hot instructions for the types it sees.
Precise Technical Explanation
Python language specification versus CPython implementation The *Python Language Reference* defines the language; CPython is the reference implementation; PyPy, GraalPy and MicroPython are others. Bytecode, reference counting and the GIL are CPython details.
Source parsing and AST The PEG parser produces an AST (ast.parse exposes it).
Compilation to code objects The compiler turns each module, class body, function and comprehension into a code object: bytecode (co_code), constants, names, variable names, free and cell variable names, flags, line/location table, exception table.
Bytecode as an implementation detail Instruction names and semantics change between versions (3.11 added specialising/adaptive instructions; 3.12 and 3.13 changed others).
Disassembles code objects for the running interpreter version: offsets, line numbers, opcodes, arguments.
Code objects and function objects A function object = code object + globals dictionary + defaults + closure cells + name/qualname/annotations.
Frame objects and execution state Each active call has an interpreter frame (instruction pointer, locals array, value stack); Python-visible frame objects (sys._getframe) are created lazily since 3.11.
Namespaces and dictionaries Module globals and class namespaces are dictionaries; instance attributes are usually stored in a per-instance dict or inline values with shared keys.
Local/global/free-variable lookup The compiler classifies names: locals → LOAD_FAST (array index), globals → LOAD_GLOBAL (dict lookup, with builtins fallback, cached via specialisation), free/cell variables → LOAD_DEREF.
Closures and cells Captured variables live in cell objects shared by the outer function (MAKE_CELL, STORE_DEREF) and inner functions (COPY_FREE_VARS, LOAD_DEREF).
Reference counting in CPython Every object has a reference count; Py_INCREF/Py_DECREF; freed at zero (Module 14). Since 3.12, some objects are *immortal* (their count never changes).
Cyclic garbage collection A generational cycle collector for container objects; gc module controls it.
Imports and sys.modules The import system caches modules in sys.modules; finders and loaders locate code; .pyc files in __pycache__ are keyed by interpreter version (module.cpython-313.pyc).
Python virtual environments and runtime versions venv creates isolated environments pointing at a base interpreter; py -3.13/pyenv select versions; packages with C extensions are built per version and platform.
Exceptions and traceback objects Exceptions carry __traceback__ (frames and line numbers), __cause__ and __context__; 3.11+ adds fine-grained error locations; exception groups and except* handle multiple errors.
Generators, coroutines, and suspended frames A generator's frame is suspended at yield and resumed by next(); coroutines (async def) suspend at await. The frame lives on the heap while suspended.
Async event loop versus interpreter execution asyncio's event loop is ordinary Python code that resumes coroutines; the interpreter executes one bytecode stream at a time — an event loop is not an interpreter.
C extensions and Python C API overview Modules written in C against the C API (or the limited/stable ABI), manipulating reference counts; tools like Cython and pybind11 generate them; errors in reference counting cause leaks or crashes.
GIL concept and version/build caveats The Global Interpreter Lock lets only one thread execute Python bytecode at a time in standard CPython builds, simplifying reference counting and C extensions; threads still help with I/O.
Free-threaded CPython overview, with version-specific status clearly marked
PEP 703 free-threaded builds (python3.13t) are experimental in 3.13 (opt-in separate build, PEP 779 later made them officially supported in 3.14): no GIL, biased reference counting, per-object locks; extensions must declare compatibility.
Profiling and tracing sys.settrace/sys.setprofile hooks, and since 3.12 the low-overhead sys.monitoring API (PEP 669).
timeit, cProfile, tracemalloc, and dis timeit for micro-measurements (with repeats), cProfile for deterministic function profiles, tracemalloc for allocation sites, dis for bytecode.
Python startup and import cost Interpreter start-up plus imports; -X importtime (Module 15) and lazy imports reduce it.
Packaging and deployment Wheels (pure or per-platform), pip, uv, zipapp, frozen executables (PyInstaller) bundling an interpreter.
Why CPython bytecode must not be treated as a stable cross-version file format
The magic number in .pyc files changes whenever bytecode changes; code objects cannot be loaded by another minor version; marshal is not secure against malicious data. Ship source (or per-version wheels), never bytecode, across versions.
Python example Bytecode inspection — the specification's example run with CPython 3.13.1 (instruction names and output vary by CPython version; dis documents implementation-specific bytecode, not a Python language guarantee):
def add(a, b):
return a + b
print(add(20, 22))import dis
def add(a, b):
return a + b
print(add(20, 22))
dis.dis(add)42
3 RESUME 0
4 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP 0 (+)
RETURN_VALUEWhy It Matters in Real Systems
CPython internals explain why attribute access and global lookups cost time, why threads do not speed up CPU-bound code in standard builds, why C extensions crash with refcount bugs, and why .pyc files break across versions.
Key Terms
Compiled bytecode plus constants, names and tables Shared box for a captured variable
Replacing generic instructions by type-specific ones at run time (3.11+)
Lock allowing one thread to run bytecode at a time Experimental 3.13 build without the GIL
Diagram, Execution Trace or State Transition
inspectorSyntax or Instruction Format
dis columns: source line, offset (shown with show_offsets=True), opcode name, argument and resolved value in parentheses.
Complete Example
Adaptive specialisation: after warm-up, CPython 3.13 replaces the generic BINARY_OP with a type-specific instruction, and re-specialises when the types change (dis(..., adaptive=True) shows the quickened instructions):
Output (CPython 3.13.1 — specialised instruction names are version-specific):
Closures and cells, local versus global lookup (addresses removed from the output):
Generators keep a suspended frame; the GIL status and build are visible at run time:
import dis
def add(a, b):
return a + b
for i in range(1000):
add(i, 1)
print("after 1000 int calls:")
dis.dis(add, adaptive=True)
for i in range(1000):
add("x", "y")
print("after 1000 str calls:")
dis.dis(add, adaptive=True)after 1000 int calls:
4 RESUME_CHECK 0
5 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP_ADD_INT 0 (+)
RETURN_VALUE
after 1000 str calls:
4 RESUME_CHECK 0
5 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP_ADD_UNICODE 0 (+)
RETURN_VALUEimport dis, io, re
COUNT = 0
def outer():
n = 0
def inner():
return n + COUNT # n: free variable (cell), COUNT: global
return inner
buf = io.StringIO()
dis.dis(outer, file=buf)
print(re.sub(r"0x[0-9A-Fa-f]+|file \".*?\"", "…", buf.getvalue()))
f = outer()
print("co_freevars:", f.__code__.co_freevars, "| co_names (globals):", f.__code__.co_names)-- MAKE_CELL 1 (n)
6 RESUME 0
7 LOAD_CONST 1 (0)
STORE_DEREF 1 (n)
9 LOAD_FAST 1 (n)
BUILD_TUPLE 1
LOAD_CONST 2 (<code object inner at …, …, line 9>)
MAKE_FUNCTION
SET_FUNCTION_ATTRIBUTE 8 (closure)
STORE_FAST 0 (inner)
12 LOAD_FAST 0 (inner)
RETURN_VALUE
Disassembly of <code object inner at …, …, line 9>:
-- COPY_FREE_VARS 1
9 RESUME 0
10 LOAD_DEREF 0 (n)
LOAD_GLOBAL 0 (COUNT)
BINARY_OP 0 (+)
RETURN_VALUE
co_freevars: ('n',) | co_names (globals): ('COUNT',)import sys, sysconfig
def countdown(n):
while n > 0:
yield n
n -= 1
g = countdown(3)
print(next(g), "| suspended at line", g.gi_frame.f_lineno, "| locals", g.gi_frame.f_locals)
print(list(g), "| frame after exhaustion:", g.gi_frame)
print("free-threaded build:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL enabled:", getattr(sys, "_is_gil_enabled", lambda: True)())3 | suspended at line 6 | locals {'n': 3}
[2, 1] | frame after exhaustion: None
free-threaded build: False
GIL enabled: TrueLine-by-Line Explanation
dis of add: RESUME marks the function start; LOAD_FAST_LOAD_FAST (a 3.13 superinstruction) pushes both locals; BINARY_OP 0 (+) adds; RETURN_VALUE returns.
Adaptive: after int calls the + became BINARY_OP_ADD_INT; after string calls the specialisation failed and the instruction re-specialised to BINARY_OP_ADD_UNICODE — the same idea as Module 16's speculation, inside an interpreter.
Cells: outer creates a cell for n (MAKE_CELL, STORE_DEREF), builds inner with a closure tuple; inner copies free variables (COPY_FREE_VARS), reads n with LOAD_DEREF and COUNT with LOAD_GLOBAL.
Generator: its frame survives between next calls with its locals; after exhaustion the frame is released (None). The build here is a standard (GIL) build.
Expected Output
As shown for CPython 3.13.1; other versions print different instructions.
Step-by-Step Runtime Behavior
add(20, 22): create frame → RESUME → push a, b → BINARY_OP: call int.__add__ fast path → push 42 → RETURN_VALUE → pop frame.
Common Misconceptions
"Python is interpreted line by line." It compiles whole modules to bytecode first.
".pyc files are portable." Only to the same minor version.
"The GIL makes Python thread-safe." It makes individual bytecodes atomic-ish, not your compound operations.
"asyncio runs Python faster." It overlaps waiting, not computation.
Common Bugs and Failure Modes
Relying on dis output in tests across versions, mutable default arguments (evaluated once at definition), late-binding closures in loops, refcount bugs in C extensions, CPU-bound threading expecting speed-ups.
Corrected or Safer Patterns
Use None defaults, bind loop variables explicitly, use multiprocessing/subinterpreters/free-threaded builds for CPU parallelism, test extensions with debug builds.
Debugging and Inspection Techniques
dis, inspect, traceback, faulthandler (dumps tracebacks on crashes), python -X dev, tracemalloc, cProfile + snakeviz, py-spy (sampling, no code changes).
Performance Considerations
Local variable access is fastest; attribute and global lookups are cached by specialisation; function calls and allocation dominate; move hot loops into vectorised libraries or compiled extensions.
Security Considerations
pickle/marshal of untrusted data executes code or crashes the interpreter; CPython does not verify bytecode; eval/exec on input is code injection.
Portability and Version Caveats
Bytecode, specialisation, frame internals, free-threading status and C API details are version-specific (labelled CPython 3.13.1 here).
Real-World Use Cases
Data science and ML (extensions do the heavy lifting), web back ends, scripting and automation, embedding Python in applications.
Best Practices
Rely on the language, not on CPython bytecode; profile before optimising; keep hot code type-stable to benefit from specialisation; pin interpreter versions in deployments.
Exercises
E1 · Beginner. Which instruction loads a local variable in CPython 3.13 bytecode?
Answer: LOAD_FAST (or LOAD_FAST_LOAD_FAST for two at once).
E2 · Intermediate. Why does BINARY_OP_ADD_INT change to BINARY_OP_ADD_UNICODE in the example?
Answer: The specialised instruction guards on both operands being ints; string arguments fail the guard repeatedly, so the adaptive interpreter de-specialises and re-specialises for the types now observed.
E3 · Advanced challenge. Why can't a .pyc produced by 3.12 be imported by 3.13?
Answer: The bytecode format and instruction set differ; the .pyc header's magic number identifies the version, and the import system ignores or recompiles caches whose magic number does not match.
Quiz
? What does `dis` show?
+ The bytecode of the running CPython version. :: Implementation- and version-specific.
- The Python language specification. :: No.
- Machine code. :: Bytecode.
- The AST. :: That is the ast module.
? How does an inner function read a captured variable in CPython?
+ Through a cell, with LOAD_DEREF. :: Cells are shared with the outer function.
- With LOAD_GLOBAL. :: That is for globals.
- With LOAD_FAST. :: That is for its own locals.
- By copying the value at definition time. :: Closures capture variables, not values.
? Which are CPython implementation details? (select all that apply)
+ Reference counting :: Other implementations use tracing GC.
+ The GIL :: PyPy has one too, but other implementations do not; free-threaded builds remove it.
- That `+` on two ints adds them :: Language semantics.
+ Adaptive specialised instructions :: Since 3.11.Summary and Key Takeaways
CPython compiles Python to version-specific bytecode in code objects and runs it in a specialising interpreter, with reference counting plus cycle collection, cells for closures, dictionaries for namespaces and a GIL in standard builds. Use dis and profiling tools to understand behaviour — and never ship bytecode across versions.
Further Reading
Python documentation: dis, "Execution model", "The import system", "Python Initialization Configuration".
PEP 659 "Specializing Adaptive Interpreter"; PEP 703 "Making the Global Interpreter Lock Optional"; PEP 669 "Low Impact Monitoring".
Anthony Shaw, *CPython Internals*.