The Python runtime — interpreter, bytecode, and startup
Where CPython lives on disk, what happens on import, and how startup time is spent.
Why this matters
Most developers treat Python as a magic box that runs print("hello"). Senior engineers know what happens when you press enter: the source is parsed, compiled to bytecode, executed by the CPython VM, with the GIL gating thread execution. Understanding this prevents the embarrassing "why is my multithreaded code not faster?" moment and unlocks the language's escape hatches (C extensions, numba, threading vs multiprocessing, the new free-threaded build).
Learning objectives
- Name the implementations of Python and pick between them.
- Explain the source → bytecode → execution pipeline.
- Understand objects, references, and the garbage collector.
- Explain the GIL and Python 3.13's free-threaded build.
- Read a
disdisassembly.
1. Implementations of Python
| Name | Notes |
|---|---|
| CPython | Reference implementation; what you have unless told otherwise. C-based. |
| PyPy | JIT-compiled; often 5-10× faster on pure-Python; lags on the latest version a year. |
| MicroPython / CircuitPython | For microcontrollers. Subset of stdlib. |
| Jython / IronPython | JVM / .NET hosts. Mostly dead; .NET has stronger alternatives. |
| GraalPy | Oracle's GraalVM Python; polyglot. |
The rest of this course assumes CPython 3.12+.
2. The pipeline: source → bytecode → VM
my_script.py ──► Parser ──► AST ──► Compiler ──► bytecode (.pyc cached)
│
▼
CPython VM (eval loop)
│
▼
OutputSource → AST
The parser (since 3.9, a PEG parser) turns text into an Abstract Syntax Tree:
import ast
tree = ast.parse("x = 1 + 2")
print(ast.dump(tree, indent=2))You can inspect, transform, and recompile ASTs — the basis of linters, formatters, and tools like pytest (which rewrites assert statements).
AST → bytecode
import dis
def f(x, y):
return x + y * 2
dis.dis(f)Output (abridged):
3 RESUME 0
LOAD_FAST x
LOAD_FAST y
LOAD_CONST 2
BINARY_OP 5 (*)
BINARY_OP 0 (+)
RETURN_VALUEBytecode is a stack-based instruction set. The CPython evaluation loop is a giant switch over these opcodes (literally — see ceval.c).
Cached as .pyc
CPython writes compiled bytecode to __pycache__/*.pyc so it doesn't re-compile next time. Safe to delete; the next import regenerates.
3. Everything is an object
In Python, literally everything is an object — integers, functions, classes, modules.
x = 42
print(type(x)) # <class 'int'>
print(id(x)) # CPython: memory address
print(x.__class__.__mro__) # method resolution orderint, str, list are all C structs (PyObject*) under the hood. Each carries a header with:
- a refcount (
ob_refcnt), - a type pointer (
ob_type), - type-specific data.
Reference counting
CPython manages memory primarily with reference counts: when refcount == 0, the object is freed immediately. Predictable destruction (__del__ fires deterministically — unlike Java's finalize).
import sys
x = []
print(sys.getrefcount(x)) # at least 2 (one local, one for the arg)Cyclic garbage collector
Refcounting can't free cycles (a.ref = b; b.ref = a). A periodic GC sweep finds and frees them.
import gc
gc.collect() # force a pass
gc.set_threshold(700, 10, 10) # tuneFor most code, you never touch gc. Long-lived web servers occasionally disable it for latency reasons.
4. Names, references, immutability
Python has names, not variables in the classical sense. A name is a label pointing to an object.
a = [1, 2, 3]
b = a # b points to the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] surprise!Compared to:
a = (1, 2, 3)
b = a # both point to the same tuple
# Cannot mutate; tuples are immutable.| Mutable | Immutable |
|---|---|
list, dict, set, custom classes | int, float, str, tuple, frozenset, bytes |
The pass-by-value vs pass-by-reference debate is wrong in Python: it's pass-by-object-reference. The function receives the same reference; whether it can mutate depends on the object's mutability.
The small-int / interned-string trap
CPython caches small integers (-5 to 256) and short identifier-like strings:
a, b = 100, 100
print(a is b) # True
a, b = 1000, 1000
print(a is b) # False (in normal scripts)Use == to compare values, is to compare identity. Almost always use ==.
5. Scopes — LEGB
Python looks up names in this order:
- Local: current function.
- Enclosing: outer functions (closures).
- Global: module-level.
- Built-in:
len,print, etc.
x = "global"
def outer():
x = "enclosing"
def inner():
# x = "local" # uncomment to shadow
print(x)
inner()
outer() # "enclosing"To modify an enclosing/global name, use nonlocal / global:
def make_counter():
count = 0
def inc():
nonlocal count
count += 1
return count
return inc6. The Global Interpreter Lock (GIL)
CPython's evaluator holds a single mutex — the GIL — while running bytecode. Only one thread runs Python at a time.
Implications:
- CPU-bound multithreading does not speed up pure-Python code.
- I/O-bound multithreading does speed up (the GIL releases during blocking I/O).
- For CPU-bound parallelism, use
multiprocessingor compiled extensions (NumPy, PyTorch, native C) which release the GIL.
The future: free-threaded Python
Python 3.13 ships an experimental no-GIL build (PEP 703). Build with --disable-gil; binaries are slightly slower single-threaded but scale across cores for pure-Python CPU work. Python 3.14 continues stabilising this. By 2027 expect it to become default; until then, treat it as a compile-time flag.
Detect at runtime (3.13+):
import sys
print(sys.version)
print(sys._is_gil_enabled()) # True on standard buildIn this course's Phase 7 we cover when free-threaded helps (and when async or multiprocessing is still better).
7. Built-in types you should know cold
# Numbers
1, 1.0, 1 + 2j # int, float, complex
0xff, 0b1010, 0o17 # hex, binary, octal
1_000_000 # numeric separators
# Booleans (subclasses of int)
True, False
isinstance(True, int) # True
# Strings (immutable, unicode)
"hello", 'world', """multi
line""", r"raw\n", f"x={1+2}"
# Bytes (immutable) and bytearray (mutable)
b"\x00\xff", bytearray(b"\x00\xff")
# Sequences
[1, 2, 3] # list
(1, 2, 3) # tuple
range(10) # lazy integer range
# Mappings and sets
{"a": 1, "b": 2} # dict (insertion-ordered since 3.7)
{1, 2, 3} # set
frozenset({1, 2}) # immutable set
# None and sentinels
None8. The REPL and IPython
The REPL (python or uv run python) is your fastest feedback loop. Use it constantly.
For a better experience:
uv add --dev ipython
uv run ipythonFeatures: tab completion, ?obj for docs, ??obj for source, %timeit for micro-bench, %run for scripts, magic for shell commands (!ls).
For data work, Jupyter (uv add jupyterlab; uv run jupyter lab) opens a notebook UI in your browser.
9. Reading a stack trace
def divide(a, b):
return a / b
def calculate():
return divide(1, 0)
calculate()Traceback (most recent call last):
File "x.py", line 5, in <module>
calculate()
File "x.py", line 4, in calculate
return divide(1, 0)
~~~~~~^^^^^^
File "x.py", line 2, in divide
return a / b
~~^~~
ZeroDivisionError: division by zeroRead bottom-up: the exception type and message tell you what; the frames tell you where. Python 3.11+ adds fine-grained location indicators (the carets pointing at a / b). Use them.
10. Cheat sheet of dis, gc, sys
import dis, gc, sys
dis.dis(func) # show bytecode
sys.getsizeof(obj) # bytes (shallow)
sys.getrefcount(obj) # refcount
sys.setrecursionlimit(10000)
gc.get_count() # GC gen counts
gc.collect()Sprinkle these into your IPython sessions when curious.
Hands-on lab (1.5 hours)
- Install IPython; play with
?,??,%timeit. - Disassemble three small functions; note opcodes for
for,if,+. - Compare
isand==for small and large ints, for strings, for lists. - Write a function that creates a reference cycle; verify it isn't freed until
gc.collect(). - Time
time.sleeprunning in a thread pool vs sequentially (I/O-bound, GIL releases) — show speedup. Then time a CPU-bound function (e.g.,sum(i*i for i in range(10**6))) and show no speedup. - Bonus: if you can build CPython 3.13 with
--disable-gil, run the CPU-bound benchmark on threads; observe scaling.
Common pitfalls
- Mutating a default argument:
def f(x=[]): x.append(1); return x. The[]is created once at definition. UseNonesentinel. - Using
isfor value comparison ("works for small ints, breaks for large"). - Assuming threads scale CPU work in standard CPython.
- Editing
.pycfiles (they regenerate; pointless). - Forgetting
__pycache__/in.gitignore.
Self-check
- What does CPython do between
python script.pyand "hello world"? - State the LEGB rule.
- Why doesn't threading speed up
[i*i for i in range(10**8)]? - Difference between
isand==. - Name three Python implementations.
References
- Fluent Python, 2nd ed., Luciano Ramalho — Chapters 1, 6, 7, 19.
- Python Cookbook, 3rd ed., Beazley & Jones.
- Inside the Python Virtual Machine, Obi Ike-Nwosu (free online).
- PEP 703 — Making the GIL Optional.
- CPython source code: https://github.com/python/cpython, particularly
Python/ceval.c. - Beazley, "Understanding the Python GIL" (PyCon talk, evergreen).
Sign in to save your progress and earn badges.