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.
Reference counting & the cycle collector
Deterministic freeing, and the cycles it can't free.
Attribute lookup & the C3 MRO
Descriptors, and how Python linearizes multiple inheritance.
Inside dict
Compact layout, perturbed probing, and the 2/3 load factor.
Bytecode & the eval loop
A stack machine, one instruction at a time.
The specializing interpreter
PEP 659: bytecode that rewrites itself for the types it sees.
The import system
sys.modules, partial initialization and circular imports.
Floating-point numerics
Rounding error, cancellation, and stable algorithms.
Amortized growth
Why append is $O(1)$, and what the over-allocation pattern looks like.
NumPy strides & views
One buffer, many arrays.
The GIL, races & free threading
What the GIL does and doesn't make atomic.
The asyncio event loop
Cooperative scheduling with a ready queue and a timer heap.
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.
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.
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).
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:
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.
class Name(Base1, Base2): .... The last class's MRO is computed with C3, and the steps of the merge are printed.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
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).
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.
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.
f(3) on the stack machineThe 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).
BINARY_OP (+) adaptdef total(xs):
t = 0
for x in xs:
t = t + x # this BINARY_OP
return t
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.
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.
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:
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).
math.ulp(μ)
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
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".
sys.getsizeof
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
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".
a = np.arange(12).reshape(3, 4) # int64, itemsize 8
a
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.
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.
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.
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))
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.
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$):
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.
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.