← Big Ideas of Python Graduate
Graduate edition · for experienced engineers

The big ideas of Python, from the interpreter up

Twelve ideas every serious Python engineer should be able to explain in terms of what CPython actually does, and say where the abstraction leaks. Each one has a lab that reproduces the mechanism: reference counts, probe sequences, bytecode, over-allocation, strides, thread switches and variance.

How to read this

Each idea has five parts. The contract is what the language guarantees. How it works describes the CPython mechanism behind it. Where it leaks lists the edge cases that show up in production and in code review. References points to the PEPs, source files and papers. The lab reproduces the mechanism step by step.

Details follow CPython 3.12 unless noted. Internals change between versions, which is itself part of the lesson: depend on the contract, and only understand the mechanism. Code comments like # 3.12 mark version-specific behaviour.

Part I

The object model

Every Python value is a heap-allocated PyObject with a reference count and a pointer to its type. Memory management, attribute access and the namespaces Python itself runs on all follow from that one struct.

01

Reference counting & the cycle collector

CPython frees most objects the instant their last reference disappears. Reference cycles need a separate collector, which finds garbage by subtracting internal references.

Contract

The language promises only that unreachable objects may be reclaimed. It promises nothing about when, and other implementations such as PyPy use tracing GCs. CPython adds a de facto guarantee that a lot of code relies on: an object with no cycles is destroyed as soon as its last reference goes away, so __del__ and weakref callbacks run at a predictable point.

How it works

Every object header holds ob_refcnt. Py_INCREF and Py_DECREF adjust it, and reaching zero calls the type's tp_dealloc immediately. Cycles never reach zero, so container objects (those with Py_TPFLAGS_HAVE_GC) are also tracked by a generational collector. To find unreachable cycles it copies each tracked object's refcount into gc_refs, then subtracts one for every reference coming from another tracked object. Objects left with $\mathtt{gc\_refs}>0$ are referenced from outside, so they are reachable roots. Everything reachable from those roots is kept, and the rest is garbage.

$$\mathtt{gc\_refs}(o)=\mathtt{refcnt}(o)-\#\{\text{references to }o\text{ from tracked containers}\}$$
import gc, sys
x = []
sys.getrefcount(x)      # 2: x, plus the call's own argument
gc.get_threshold()      # (700, 10, 10) in 3.12

Where it leaks

(i) Cycles delay finalization until a collection runs. Exception tracebacks are a common source, because frames reference locals and an exception stored in a local references the frame. (ii) Before PEP 442 (3.4), cycles with __del__ could never be freed and ended up in gc.garbage. (iii) Immortal objects (PEP 683, 3.12): None, small ints and interned strings have a fixed refcount, so sys.getrefcount(None) returns a huge constant. (iv) The free-threaded build uses biased reference counting, so the "freed instantly" rule gets weaker across threads. (v) gc.freeze() exists because the collector writing to every object's header defeats copy-on-write after fork.

References

CPython Python/gc.c and the devguide's "Garbage collector design"; PEP 442 (safe object finalization); PEP 683 (immortal objects); PEP 703 (free threading and biased reference counting, after Choi, Shull & Torrellas, PACT 2018); Bacon & Rajan, "Concurrent cycle collection in reference counted systems" (ECOOP 2001).

Lab · Refcounts and a cycle
Strong reference Weak reference (doesn't count)
02

Attribute lookup & the C3 MRO

obj.attr follows a fixed algorithm: data descriptors on the type, then the instance dict, then everything else on the type, searched along a linearization of the class hierarchy.

Contract

object.__getattribute__(obj, name) looks up name along type(obj).__mro__. If the attribute found there is a data descriptor (it defines __set__ or __delete__), its __get__ wins. Otherwise obj.__dict__[name] wins if present. Otherwise a non-data descriptor's __get__ is called (this is how functions become bound methods), or the plain class attribute is returned. Only if all of that fails is __getattr__ called. property, classmethod, staticmethod, __slots__ members and methods are all descriptors.

The MRO is the C3 linearization:

$$L[C]=C+\operatorname{merge}\big(L[B_1],\ldots,L[B_n],\,[B_1,\ldots,B_n]\big),$$

where merge repeatedly takes the first head that doesn't appear in the tail of any list. If no head qualifies, there is no consistent order and class creation raises TypeError.

How it works

C3 guarantees three properties. Local precedence: the order of bases in each class statement is kept. Monotonicity: a class's MRO is a subsequence-preserving extension of each parent's MRO. Extended precedence graph consistency. Together they make cooperative super() work: super().m() means "the next class in type(self).__mro__ after this one," which may be a sibling, not a parent. Lookups are cached per type in a method cache keyed by type version tags, so the walk is usually skipped.

class Base:
    def save(self): print("Base")
class Logged(Base):
    def save(self): print("Logged"); super().save()
class Cached(Base):
    def save(self): print("Cached"); super().save()
class Model(Logged, Cached): pass
Model().save()   # Logged, Cached, Base: super() reached a sibling

Where it leaks

(i) Inconsistent hierarchies are rejected outright, as in the lab's third preset. (ii) Implicit dunder lookup skips the instance: len(x) uses type(x).__len__, so setting x.__len__ on an instance does nothing. (iii) __getattr__ runs only after normal lookup fails, while __getattribute__ runs on every access, which slows everything down and makes infinite recursion easy. (iv) A property that raises AttributeError by accident silently falls through to __getattr__, hiding the real error.

References

Barrett, Cassels, Haahr, Moon, Playford & Withington, "A monotonic superclass linearization for Dylan" (OOPSLA 1996); Simionato, "The Python 2.3 Method Resolution Order"; Hettinger, "Python's super() considered super!" (2011); the Descriptor HowTo Guide; Objects/typeobject.c.

Lab · Linearize a class hierarchy
Edit the classes. Each line is class Name(Base1, Base2): .... The last class's MRO is computed with C3, and the steps of the merge are printed.
03

Inside dict

A dict is a sparse index table over a dense, insertion-ordered entry array. Collisions are resolved by a probe sequence that mixes in the high bits of the hash.

Contract

Average $O(1)$ lookup, insertion and deletion for keys with a well-distributed __hash__ consistent with __eq__. Iteration follows insertion order (guaranteed since 3.7). Mutating a dict's size while iterating over it raises RuntimeError.

How it works

Since 3.6 the table is split in two. The indices array has $2^k$ slots, each holding a small integer index or EMPTY/DUMMY. The entries array stores (hash, key, value) in insertion order. This saves memory, because the sparse part holds 1-byte indices for small tables, and it gives ordered iteration for free. A lookup starts at slot i = hash & mask and probes

$$\texttt{perturb}\mathrel{>>=}5,\qquad i\leftarrow(5i+1+\texttt{perturb})\bmod 2^k.$$

The recurrence $i\mapsto 5i+1 \pmod{2^k}$ alone visits every slot. Mixing in perturb brings in the high hash bits, so keys that agree in their low bits don't trace the same path. The table is resized when the usable entries, $\lfloor 2\cdot2^k/3\rfloor$, run out. Deleted entries still use up that budget until the next resize.

hash(8), hash(16), hash(-1)   # (8, 16, -2): small ints hash to themselves
{0: 0, 8: 0, 16: 0}            # all start at slot 0 of an 8-slot table

Where it leaks

(i) Hash flooding: adversarial keys that all collide make each operation $O(n)$. That's why str and bytes hashes are randomized per process (PEP 456, SipHash), while ints are not. (ii) Mutable keys break the invariant: a key whose hash changes after insertion becomes unreachable. (iii) Deletes don't shrink the table, so a dict that once held a million items keeps its memory until it is copied. (iv) The details here, including the key-sharing dicts and inline values used for instance attributes, are CPython-specific and changed in 3.6 and again in 3.11–3.13.

References

Objects/dictobject.c (the long comment at the top explains the probe sequence); Raymond Hettinger's 2012 python-dev proposal "More compact dictionaries with faster iteration"; PEP 412 (key-sharing dictionaries); PEP 456; Crosby & Wallach, "Denial of service via algorithmic complexity attacks" (USENIX Security 2003).

Lab · CPython's probe sequence, with int keys
Probed slot (occupied by another key) Final slot DUMMY (deleted)
Used 0 Usable left 5 Probes —
Part II

Execution

Source code becomes a code object, a code object is interpreted one instruction at a time, and since 3.11 those instructions rewrite themselves to suit the types they actually see. Modules are executed the same way, once, and cached.

04

Bytecode & the eval loop

CPython is a stack machine. Every Python-level operation is a handful of instructions dispatched by one big loop, and that dispatch is the cost floor of pure-Python code.

Contract

Bytecode is an implementation detail. Its format can change in any minor release, and .pyc files carry a magic number for exactly that reason. What's stable is the dis module as an inspection tool, and the semantics the bytecode implements.

How it works

The compiler turns the AST into a code object holding co_code (2-byte instructions plus inline cache entries), constants, names and a table of local variable slots. _PyEval_EvalFrameDefault runs a frame: fetch an instruction, jump to its handler (via computed gotos, generated from Python/bytecodes.c since 3.12), and manipulate a value stack. Locals live in an array indexed by LOAD_FAST's argument, which is why local variables are faster than globals, which go through a dict lookup (cached since 3.11). Each loop iteration in the lab costs nine instructions, each with its own dispatch, reference-count traffic and type checks.

import dis
def f(n):
    total = 0
    for i in range(n):
        total += i * i
    return total
dis.dis(f)

Where it leaks

(i) Instruction counts are the real cost model for pure Python. sum(i*i for i in range(n)) and math.sumprod win by doing the loop in C. (ii) Atomicity is per instruction, not per statement. total += 1 is a load, an add and a store, and that matters for threads (idea 10). (iii) Tracing and profiling hooks (sys.settrace, and sys.monitoring since 3.12) run inside this loop, so a debugger can slow code down 10× or more. (iv) Don't depend on specific opcodes: BINARY_ADD and friends became BINARY_OP in 3.11, and 3.13 renamed and merged others.

References

Python/bytecodes.c and Python/ceval.c; the dis documentation; PEP 523 (frame evaluation API); Ertl & Gregg, "The structure and performance of efficient interpreters" (2003). The lab's listing is simplified 3.12 output with offsets and cache entries left out.

Lab · Run f(3) on the stack machine
Instructions executed 0 Per loop iteration 9 Return value —
05

The specializing adaptive interpreter

Since 3.11, generic instructions watch the types flowing through them and replace themselves with specialized versions, then fall back when the guess stops paying off.

Contract

Semantics are unchanged. Specialization is invisible except through speed and dis.dis(f, adaptive=True). Code whose types are stable at each site gets faster, and code that's polymorphic at a site gets the generic path.

How it works

PEP 659 gives each specializable instruction inline cache entries holding a counter. After a short warm-up, the instruction specializes for the operand types it is currently seeing. BINARY_OP becomes BINARY_OP_ADD_INT, BINARY_OP_ADD_FLOAT or BINARY_OP_ADD_UNICODE. LOAD_ATTR becomes LOAD_ATTR_INSTANCE_VALUE, and so on. A specialized instruction starts with a cheap guard (is this still an int?). If the guard fails, it runs the generic code. Repeated failures make it de-specialize and try again after an exponential back-off. This is quickening without a JIT. The tier-2 optimizer and experimental copy-and-patch JIT in 3.13 (PEP 744) build on the same type information.

Where it leaks

(i) Polymorphic call sites, where the same line sees ints and floats, keep falling back to generic code. Keep hot loops type-stable. (ii) Specialization makes microbenchmarks version-dependent: an idiom that was slow in 3.10 may be fine in 3.12. (iii) Changing classes at runtime (monkey-patching, setting attributes on types) bumps type version tags and invalidates caches across the program. (iv) The thresholds and instruction names are internal and change between releases, so the lab uses a simplified model.

References

PEP 659 (Shannon, 2021); the "Faster CPython" project; Hölzle, Chambers & Ungar on polymorphic inline caches (ECOOP 1991); Brunthaler, "Inline caching meets quickening" (ECOOP 2010); PEP 744 (JIT compilation).

Lab · Watch BINARY_OP (+) adapt
def total(xs):
    t = 0
    for x in xs:
        t = t + x     # this BINARY_OP
    return t
Specialized fast path Guard failed (deopt) Generic / warming up
Instruction now BINARY_OP Operand types — Fast-path share —
A simplified model: warm-up of 2 executions, de-specialize after 4 consecutive guard failures, back-off doubling. Real thresholds are internal and vary by version.
06

The import system

Importing a module executes it once, top to bottom, after first registering it in sys.modules. A circular import sees the other module half-built.

Contract

import m checks sys.modules["m"]. On a miss it asks each finder on sys.meta_path for a spec, creates the module object, inserts it into sys.modules, and then executes the module's code in the module's namespace. Later imports return the cached object without running the code again.

How it works

Registering before executing is what makes cycles terminate. If a imports b and b imports a, the inner import a finds the partially initialized a already in sys.modules and binds it immediately. Anything a defines after its import b line doesn't exist yet. Accessing it at b's top level fails, while accessing it later, inside a function called after both modules have finished, works. from a import X fails earlier still, because it reads the attribute at import time.

AttributeError: partially initialized module 'a' has no attribute 'X'
(most likely due to a circular import)

Where it leaks

(i) Import-time side effects run exactly once per process, in whatever order the import graph happens to produce. Keep module top levels to definitions. (ii) Running a module as a script gives it the name __main__. If it is also imported under its real name, it executes twice and you get two copies of every class. (iii) Reloading with importlib.reload re-executes the code in the same namespace, but existing references still point to the old objects. (iv) Import cost matters for CLIs: python -X importtime shows where startup time goes, and lazy imports remain a recurring request (PEP 690's version was rejected in 2022).

References

The reference manual's "The import system"; PEP 302 (import hooks); PEP 451 (ModuleSpec); Brett Cannon's importlib, which made the import system pure Python in 3.3; Lib/importlib/_bootstrap.py.

Lab · Step through a circular import
a.py
b.py
Part III

Numbers & memory

Floats have a precise error model, containers have a precise growth policy, and arrays have a precise memory layout. Knowing all three turns "Python is slow and imprecise" into specific, fixable causes.

07

Floating-point numerics

Each operation is exact and then rounded once. The danger isn't rounding as such. It's subtracting two nearly equal rounded numbers, which turns tiny relative errors into large ones.

Contract

Python's float is IEEE 754 binary64 on every platform CPython supports. Basic operations are correctly rounded:

$$\mathrm{fl}(x\circ y)=(x\circ y)(1+\delta),\qquad |\delta|\le u=2^{-53}\approx1.1\times10^{-16}.$$

math.fsum returns the correctly rounded sum of its inputs. math.ulp, math.nextafter and float.hex expose the representation directly.

How it works

Rounding is relative, so it's harmless until cancellation. If $a\approx b$, then $\mathrm{fl}(a)-\mathrm{fl}(b)$ has an absolute error of about $u\max(|a|,|b|)$, but the result is small, so its relative error is about $u\,|a|/|a-b|$. The textbook one-pass variance $\frac1n\sum x_i^2-\bar x^2$ subtracts two numbers of size $\mu^2$ to get $\sigma^2$. Its relative error grows like $u\,\mu^2/\sigma^2$, and once $\mu/\sigma\gtrsim10^8$ the result is noise, and can even be negative. Welford's update and the two-pass algorithm work with deviations from the mean and are stable. statistics.variance goes further and computes with exact fractions.

Where it leaks

(i) Summation order matters. sum() uses Neumaier compensated summation for floats since 3.12, so results can differ across versions. (ii) Equality is fragile: use math.isclose with an explicit abs_tol near zero. (iii) NaN breaks reflexivity (nan != nan), but containers check identity first, so nan in [nan] is True for the same object. (iv) Decimal doesn't fix cancellation. It only moves the base to 10 and lets you choose the precision.

References

Goldberg, "What every computer scientist should know about floating-point arithmetic" (ACM Computing Surveys, 1991); Higham, Accuracy and Stability of Numerical Algorithms (2nd ed., 2002); Welford (Technometrics, 1962); Shewchuk (1997), the basis of math.fsum; Neumaier (1974).

Lab · Variance of data far from zero
One-pass $E[x^2]-\bar x^2$ Welford Prediction $u\,\mu^2/\sigma^2$
True variance 1 (by construction) One-pass Welford math.ulp(μ)
08

Amortized growth

list.append is $O(1)$ amortized because the list over-allocates by a constant factor. The pattern is visible in sys.getsizeof.

Contract

The documented costs (the "TimeComplexity" wiki page): append, pop() and indexing are amortized $O(1)$. insert(0, x) and pop(0) are $O(n)$. Use collections.deque for queues.

How it works

A list is an array of PyObject* with ob_size (length) and allocated (capacity). When append finds them equal, list_resize (3.12) asks for

$$\texttt{new\_allocated}=\big(n+\lfloor n/8\rfloor+6\big)\ \&\ \sim3,$$

which gives 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, … Growth by a factor $r=1.125$ means the total elements moved across all resizes up to size $n$ is at most $\sum_k n r^{-k}\approx n\cdot\frac{r}{r-1}=9n$, so each append moves $O(1)$ elements on average. A small factor wastes little memory, and realloc can often grow the block in place, so the real copying cost is usually lower than that bound.

import sys
xs = []; sizes = []
for k in range(10):
    sizes.append(sys.getsizeof(xs)); xs.append(k)
sizes   # [56, 88, 88, 88, 88, 120, 120, 120, 120, 184]

Where it leaks

(i) Amortized is not worst-case. One append can copy the whole list, which matters for latency-sensitive code. (ii) Different constructors allocate differently: [None] * k, list comprehensions and list(iterable) pre-size or grow on different schedules, so sys.getsizeof varies by how the list was built. (iii) Lists of floats store pointers to boxed float objects, about 32 bytes per element in total. array.array or NumPy store 8. (iv) The growth formula has changed several times (3.9 smoothed it), so don't hard-code it.

References

Objects/listobject.c (list_resize); Cormen et al., Introduction to Algorithms, ch. 17 (amortized analysis, dynamic tables); Tarjan, "Amortized computational complexity" (1985); the python.org wiki page "TimeComplexity".

Lab · Append and watch the capacity
allocated len Resize
Resizes Elements moved (upper bound) / n Wasted slots sys.getsizeof
09

NumPy strides & views

An ndarray is a pointer, a shape and a stride per axis. Transposes, slices and broadcasts only change those numbers, so they are free. Anything a stride can't express needs a copy.

Contract

Basic slicing (a[1:, ::2]), .T, reshape when the layout allows it, and broadcast_to return views that share memory with the original. Fancy indexing (a[[0, 2]]) and boolean masks return copies. np.shares_memory tells you which you got.

How it works

Element $(i_0,\ldots,i_{d-1})$ lives at byte address

$$\texttt{data}+\sum_k i_k\,\texttt{strides}[k].$$

A C-contiguous $3\times4$ int64 array has strides $(32,8)$. Its transpose has $(8,32)$, with the same buffer. a[:, ::2] doubles the second stride. a[::-1] uses a negative stride and moves the data pointer. Broadcasting sets a stride to 0, so one row is read repeatedly without being copied. reshape succeeds as a view only if the new shape can be produced by splitting or merging axes with compatible strides. Otherwise NumPy copies silently. Vectorized operations are fast because their inner loops are C loops that walk strides, not Python loops that dispatch bytecode (idea 4).

Where it leaks

(i) Writes through a view change the original, which is a common source of spooky action at a distance. (ii) Strided access defeats the cache: summing along a transposed axis is slower than along a contiguous one, and some operations quietly make contiguous copies. (iii) Broadcast views are read-only for a reason: writing through a stride-0 axis would write the same cell repeatedly. (iv) np.lib.stride_tricks.as_strided can create views that point outside the buffer, which is undefined behaviour.

References

van der Walt, Colbert & Varoquaux, "The NumPy array: a structure for efficient numerical computation" (CiSE, 2011); Harris et al., "Array programming with NumPy" (Nature, 2020); PEP 3118 (the buffer protocol); NumPy's "Internal organization of NumPy arrays".

Lab · One buffer, many views
a = np.arange(12).reshape(3, 4)   # int64, itemsize 8
shape strides (bytes) offset C / F contiguous Shares memory with a
Part IV

Concurrency

Two models with different failure modes. Preemptive threads can interleave at any instruction boundary the runtime allows. Cooperative coroutines interleave only at await. Knowing exactly where switches can happen is the whole game.

10

The GIL, races & free threading

The GIL makes each bytecode instruction atomic with respect to other threads. It doesn't make your statements atomic, and without the GIL even that guarantee goes.

Contract

The language makes very few atomicity promises. Some built-in operations are documented as thread-safe in CPython (list.append, dict[k] = v, queue.Queue). Compound operations such as x += 1, check-then-act sequences and iterating while another thread mutates need a threading.Lock, whichever build you run.

How it works

With the GIL, a thread holds the interpreter until another thread asks for it. The holder notices the request at an "eval breaker" check and drops the GIL once the switch interval (sys.getswitchinterval(), 5 ms by default) has passed. Since 3.10 those checks happen only at specific points, mainly backward jumps and function entry. So a straight-line LOAD, BINARY_OP, STORE sequence is rarely interrupted in practice, which hides races that used to show up immediately. The free-threaded build (PEP 703, supported since 3.14) removes the GIL. Threads truly run at once, and built-in containers protect themselves with per-object locks, but += on a shared variable is still three separate steps.

counter = 0
def work():
    global counter
    for _ in range(100_000):
        counter += 1      # LOAD_GLOBAL, LOAD_CONST, BINARY_OP, STORE_GLOBAL

Where it leaks

(i) "It never failed in testing" is weak evidence. Interleavings depend on version, load and switch points, and 3.10's change made many latent races disappear without fixing them. (ii) C extensions release the GIL during I/O and heavy computation (NumPy, hashlib, zlib). That's why threads help I/O-bound and NumPy-heavy code even with the GIL. (iii) Free-threading costs single-thread speed, and every C extension has to declare that it supports the free-threaded build. (iv) multiprocessing sidesteps the GIL but pays for pickling and process start-up. concurrent.interpreters (PEP 734, 3.14) offers isolated interpreters within one process.

References

PEP 703 (Gross, 2023) and PEP 779 (supported status, 3.14); David Beazley, "Understanding the Python GIL" (PyCon 2010); Antoine Pitrou's new GIL (3.2); Python/ceval_gil.c; PEP 684 (per-interpreter GIL) and PEP 734.

Lab · Two threads, one counter
Expected 20,000 Got — Lost updates —
An interleaving that loses an update. ≤3.9 could switch threads after any instruction; 3.10+ switches only at back-edges and calls.
A model, not a measurement: switches are random at the allowed points. The 3.10+ setting loses nothing here because the loop body contains no switch point, but that is an accident of the current eval loop, not a guarantee.
11

The asyncio event loop

An event loop is a ready queue, a heap of timers and a selector, run by one thread. Coroutines give up control only at await, so code between awaits is atomic, and a blocking call stops everything.

Contract

Inside one event loop, no two coroutines run at the same moment, and a coroutine is never interrupted between await points. asyncio.gather and TaskGroup (3.11) run tasks concurrently. asyncio.to_thread pushes blocking calls onto a thread pool. Cancellation is delivered as CancelledError at an await.

How it works

A task wraps a coroutine. Running one step means calling coro.send(None) until it yields a Future. The task then registers a callback on that future and goes back to the loop. Each iteration of BaseEventLoop._run_once works out how long it may sleep (until the earliest timer, or zero if there are ready callbacks), polls the selector for I/O, moves expired timers to the ready queue, and runs every callback that was ready at the start of the iteration. await asyncio.sleep(d) is just a timer on that heap.

Where it leaks

(i) Blocking calls freeze the loop, delaying every other task and timer. Run with PYTHONASYNCIODEBUG=1 to log slow callbacks. (ii) Forgotten tasks: the loop keeps only weak references to tasks, so a task you created and didn't store can be garbage-collected mid-flight. (iii) Function colour: async functions can only be awaited from async code, which splits libraries into sync and async versions. (iv) Cancellation is cooperative, so a coroutine that catches CancelledError and carries on breaks timeouts and task groups.

References

PEP 3156 (asyncio, Guido van Rossum, 2012); PEP 492 (async/await); PEP 654 (exception groups, TaskGroup); Nathaniel J. Smith, "Notes on structured concurrency" (2018); Lib/asyncio/base_events.py.

Lab · Step the event loop
async def worker(name, delay):
    print(f"{name} start")
    await asyncio.sleep(delay)     # or time.sleep(delay) for B
    print(f"{name} done")

await asyncio.gather(worker("A", 2), worker("B", 1), worker("C", 1.5))
Part V

Types

Python's type hints are erased at runtime and checked by external tools. The theory behind those tools, subtyping and variance, decides which assignments are sound.

12

Gradual typing & variance

A generic type is covariant if it only produces values, contravariant if it only consumes them, and invariant if it does both. Mutable containers are invariant.

Contract

Annotations have no runtime effect (apart from tools that read them). Type checkers such as mypy and pyright implement gradual typing: Any is compatible in both directions, which lets typed and untyped code mix. For a generic $G$ and $A<:B$ ($A$ is a subtype of $B$):

$$\begin{aligned}&\text{covariant: } G[A]<:G[B] &&\texttt{Sequence},\ \texttt{tuple},\ \texttt{frozenset},\ \text{return types}\\ &\text{contravariant: } G[B]<:G[A] &&\text{callable parameters}\\ &\text{invariant: neither} &&\texttt{list},\ \texttt{dict},\ \texttt{set}\end{aligned}$$

How it works

Variance follows from where the type parameter appears. If list[int] were a list[object], you could append a str through the list[object] view and then read it back through the list[int] name, which breaks soundness. Java arrays are covariant for historical reasons and pay with a runtime ArrayStoreException. A function that accepts any object can stand in wherever a function accepting int is expected, but not the other way around. PEP 695 (3.12) lets checkers infer variance for new-style type parameters.

Where it leaks

(i) Any is contagious: one untyped dependency silently turns checks off downstream. Prefer object when you mean "anything, but check it." (ii) Hints aren't enforced at runtime, so validate data at your system's boundaries (pydantic, attrs validators). (iii) Checkers disagree at the edges: mypy and pyright differ on inference and narrowing. (iv) Some unsoundness is deliberate. For example, int is accepted where float is expected, by special case (PEP 484's numeric tower shortcut).

References

Siek & Taha, "Gradual typing for functional languages" (2006); PEP 483 (the theory of type hints) and PEP 484; PEP 544 (protocols); PEP 695; Pierce, Types and Programming Languages, ch. 15; the mypy docs on variance.

Lab · Would a type checker accept this?
Synthesis

Four threads through the twelve

Contract vs. mechanism

Refcounting, dict layout, bytecode, specialization and list growth (ideas 1, 3, 4, 5, 8) are CPython mechanisms that have all changed between releases. Write code against the contract and use the mechanism to explain performance.

Dispatch is the cost

Attribute lookup, the eval loop, specialization and vectorization (ideas 2, 4, 5, 9) all come down to one fact: dynamic dispatch per operation is expensive, and fast Python either caches the dispatch or moves the loop into C.

Where interleaving happens

Circular imports, thread switches and await points (ideas 6, 10, 11) are all about seeing a half-finished state. Correct programs either can't observe intermediate states or protect them explicitly.

Soundness by construction

Stable float algorithms, C3's consistency checks and variance rules (ideas 2, 7, 12) reject plausible-looking choices up front so that whole classes of errors can't happen later.

A reading list

  • The Python Language Reference and the CPython Developer's Guide (docs.python.org, devguide.python.org), especially "Data model", "The import system" and the internals docs.
  • Luciano Ramalho, Fluent Python (2nd ed., 2022). The data model, descriptors, concurrency and typing, carefully done.
  • Anthony Shaw, CPython Internals (2021). A guided tour of the interpreter's source.
  • Micha Gorelick & Ian Ozsvald, High Performance Python (2nd ed., 2020). Profiling, NumPy, compilation and concurrency.
  • Nicholas J. Higham, Accuracy and Stability of Numerical Algorithms (2nd ed., 2002), for idea 7.
  • The PEPs cited above, particularly 659 (specialization), 703 (free threading), 3156 (asyncio) and 483/484 (type hints). Each one records the design trade-offs, not just the outcome.