Track B — Warmup: The CPython Runtime, From Zero
Self-contained. Every mechanism explained from the interpreter up, with the code that proves it. You should not need to run the experiment scripts to understand this file — but you should run them anyway, because predicting output before you see it is the drill.
Reported: Python internals surfaced during the systems-flavored coding round, specifically generators, async constructs, and iterators.
Table of Contents
- Chapter 0: How These Questions Actually Arrive
- Chapter 1: The Object Model
- Chapter 2: Iterators and Generators
- Chapter 3: Async
- Chapter 4: The GIL and Concurrency
- Chapter 5: Memory
- Chapter 6: The Data Model
- The Justification Drill
- References
Chapter 0: How These Questions Actually Arrive
Nobody asks "explain the descriptor protocol." They ask:
"You used a generator here instead of returning a list. What does that buy you, and what does it cost?"
"This is an async function but you called a blocking library. What happens?"
"You're holding all of these in memory. How much is that actually?"
The reported observations — "Coding 2 was systems-flavored: state management, concurrency, memory efficiency" and "Python internals came up: generators, async, iterators" — are one observation, not two. You are asked to build a stateful streaming component, and the internals questions arise from your own implementation choices.
So preparing internals as trivia is the wrong shape. Preparing them as justifications for a choice you made thirty seconds ago is the right shape. Read every section below asking: what design decision does this let me defend?
Chapter 1: The Object Model
1.1 Everything is a PyObject
Every Python value — an int, a function, a class, a module — is a C struct beginning with:
typedef struct _object {
Py_ssize_t ob_refcnt; /* how many references point here */
PyTypeObject *ob_type; /* what type this is */
} PyObject;
Two fields, and both matter for questions you will be asked. ob_refcnt drives deallocation
(§1.3). ob_type is why type(x) is O(1) and why "everything is an object" is literally true
rather than a slogan — a class is a PyObject whose type is type.
A consequence worth having ready: there are no primitives. An int is a heap-allocated
object with a header. That is why a Python list of a million integers costs vastly more than a
C array of a million ints — you pay for a million object headers plus a million pointers.
It is also why numpy exists.
1.2 Names are bindings, not boxes
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4]
a and b are two names bound to the same object, not two boxes holding copies.
Assignment binds a name; it never copies.
This explains the mutable-default-argument trap, and explains it correctly:
def f(items=[]): # the list is created ONCE, when the def executes
items.append(1)
return items
f() # [1]
f() # [1, 1] <- same list object, still bound to the default
The default is evaluated once at function-definition time and stored on the function object
(f.__defaults__). It is not re-evaluated per call. The fix is items=None plus
if items is None: items = [].
1.3 Reference counting
CPython frees an object the instant its refcount hits zero. Refcounts change on binding, appending to a container, passing to a function, and so on.
import sys
x = object()
print(sys.getrefcount(x)) # 2 — one for `x`, one for getrefcount's own argument
y = x
print(sys.getrefcount(x)) # 3
del y
print(sys.getrefcount(x)) # 2
Why the off-by-one is worth explaining: passing x to getrefcount creates a temporary
reference. Knowing that is a small signal that you understand the mechanism rather than the API.
Properties of refcounting — and it is a genuine engineering tradeoff, not obviously the right choice:
- ✅ Deterministic and prompt. The object dies at the exact statement that drops the last
reference. This is why
with open(...)closing works reliably in CPython, and why context managers are still the correct answer (PyPy and Jython do not refcount). - ✅ No pause times.
- ❌ Cannot collect cycles — §1.4.
- ❌ Every reference operation touches memory, which hurts cache locality.
- ❌ The refcount field must be atomic under free-threading, which is a large part of that build's overhead.
1.4 The cycle collector
Refcounting cannot free this:
a = {}; b = {}
a['b'] = b; b['a'] = a
del a, b # each still has refcount 1, from the other
So CPython adds a generational mark-and-sweep collector for container objects.
Generational hypothesis: most objects die young. So the collector keeps three generations.
New objects go in gen 0, which is collected often; survivors are promoted to gen 1, then gen 2,
each collected progressively less often. The thresholds are (gen0, gen1, gen2): gen 0 runs
after that many more allocations than deallocations, gen 1 after that many gen-0 collections,
gen 2 after that many gen-1 collections.
Do not memorize the numbers. The long-documented default was
(700, 10, 10); on CPython 3.13 it is(2000, 10, 10), and 3.13 also introduced an incremental collector that changes the pause characteristics. Say the shape, thengc.get_threshold(). This is a live example of the confident-wrong failure mode: a number you learned from a blog in 2019 that an interviewer running 3.13 will correct you on.
How it finds cycles: for each object in the generation, subtract the references that come from inside the generation. Anything left with a nonzero count is reachable from outside and is live; the rest, and everything reachable only from them, is garbage.
Only objects that can participate in cycles are tracked — containers. An int or a str
cannot reference anything, so it is never tracked, which is why gc overhead is proportional to
container count rather than object count.
Practical notes worth having: gc.freeze() before forking moves everything to a permanent
generation so the child's collector does not touch (and thus copy-on-write-fault) parent pages —
a real production trick for pre-fork servers. And gc.disable() is occasionally correct for a
short-lived batch process that creates no cycles, though it is usually a bad idea.
1.5 __del__, and why weakref.finalize is better
class Node:
def __init__(self): self.ref = None
def __del__(self): print("finalized")
a, b = Node(), Node()
a.ref = b; b.ref = a
del a, b
import gc; gc.collect() # both print "finalized"
Before Python 3.4 (PEP 442) this leaked: objects with __del__ in a cycle were considered
uncollectable and dumped into gc.garbage, because the collector could not determine a safe
finalization order. PEP 442 changed finalization so cycles containing finalizers are collected.
The remaining hazards, which is the actual answer to "should I use __del__":
- Finalization order within a cycle is undefined —
__del__may run on an object whose peers are already finalized, so touching them is unsafe. - Exceptions inside
__del__are swallowed and printed to stderr. You cannot handle them. - Resurrection:
__del__can storeselfsomewhere and revive the object. - It may not run at interpreter shutdown at all.
So: use a context manager for scoped resources, and weakref.finalize when you need
cleanup tied to an object's lifetime:
import weakref
class Resource:
def __init__(self, name):
self.name = name
weakref.finalize(self, lambda n=name: print(f"released {n}"))
The lambda n=name: matters — capturing self in the callback would keep the object alive
forever, defeating the entire purpose. That detail is a good one to volunteer.
Chapter 2: Iterators and Generators
2.1 The iterator protocol, exactly
for item in thing:
body(item)
compiles to approximately:
_it = iter(thing) # calls type(thing).__iter__(thing)
while True:
try:
item = next(_it) # calls type(_it).__next__(_it)
except StopIteration:
break
body(item)
Two dunders and one exception. That is the whole protocol.
iter(x) has a fallback worth knowing: if x has no __iter__ but has __getitem__, Python
builds an iterator that calls x[0], x[1], … until IndexError. This is the old sequence
protocol and it is why some ancient classes iterate without defining __iter__.
StopIteration is control flow, not an error. That has one sharp consequence: if a
StopIteration escapes from inside a generator body, it used to silently truncate the
generator. PEP 479 (default since 3.7) fixed this — such an escape now becomes a RuntimeError.
The practical upshot: never call next() without a default inside a generator unless you
mean to end it.
2.2 Iterable versus iterator
| Iterable | Iterator | |
|---|---|---|
| Implements | __iter__ | __iter__ and __next__ |
__iter__ returns | a new iterator | self |
| Holds position | no | yes |
| Reusable | yes | no — exhausted once |
iter(x) is x holds for iterators, not for iterables. Two bugs follow from confusing them:
class Broken:
def __init__(self, n): self.n = n
def __iter__(self):
while self.n > 0: # reads and mutates INSTANCE state
yield self.n
self.n -= 1
b = Broken(3)
list(b) # [3, 2, 1]
list(b) # [] <- silently single-use
__iter__ is a generator function, so each call returns a fresh generator — but they all read
and write the same self.n, which the first pass drove to zero. The fix keeps iteration state
local: for i in range(self.n, 0, -1): yield i.
The second bug is nested loops over the same iterator silently sharing a cursor:
it = iter([1, 2, 3])
for a in it:
for b in it: # consumes the SAME cursor
print(a, b) # prints only "1 2" then "1 3"
2.3 What a generator actually is
A function containing yield is a generator function. Calling it runs no code — it
returns a generator object.
def gen():
print("starting")
yield 1
g = gen() # nothing printed
next(g) # NOW "starting" prints, then it yields 1
The generator object holds a suspended frame: local variables, the instruction pointer, and
the evaluation stack. next() resumes that frame; yield suspends it and returns a value.
That is the key mental model: a generator is a function whose stack frame outlives its first return, and can be resumed. Which is why a generator is a natural state machine — the "current state" is simply where the frame is suspended, with no explicit state variable and no dispatch table:
def protocol():
header = yield "awaiting header"
size = int(header)
body = []
while len(body) < size:
chunk = yield f"awaiting body ({len(body)}/{size})"
body.append(chunk)
yield f"complete: {body}"
Three states, zero enum, zero dispatch. Compare with the class-based version and the difference is the whole argument for generators as state machines.
Memory: a generator holds one frame regardless of how many items it produces. A list holds all of them. Measured: a 2-million-element list comprehension costs ~77 MiB; the equivalent generator expression costs ~400 bytes.
The honest caveat, which is the follow-up: a generator is only cheaper if you never need the data twice. Re-iterating means re-computing, and if the source is I/O that trade can lose badly.
2.4 send, throw, close
yield is an expression, not a statement. Its value is whatever is sent in.
def echo():
total = 0
while True:
received = yield total # yields total, receives the sent value
total += received
g = echo()
g.send("hello") # TypeError: can't send non-None value to a just-started generator
Why: a fresh generator is suspended before its first yield, so there is no yield
expression waiting to receive anything. You must prime it — next(g) or g.send(None) — to
advance to the first yield.
g = echo()
next(g) # 0 — primes it
g.send(5) # 5
g.send(7) # 12
throw(exc) raises the exception at the suspended yield, as if that expression had
raised. Three possible outcomes, and being able to list all three is the complete answer:
- The generator does not catch it → it propagates out of
throw()and the generator closes. - The generator catches it and yields again →
throw()returns that value. - The generator catches it and returns →
throw()raisesStopIteration.
close() throws GeneratorExit at the suspended yield. This is how cleanup runs:
def with_cleanup():
try:
yield 1
yield 2
finally:
print("cleanup") # runs on close(), and on garbage collection
g = with_cleanup()
next(g)
del g # refcount hits 0 -> close() -> GeneratorExit -> finally
The practical consequence: a for loop that breaks early leaves the generator suspended.
When it is collected, close() runs the finally — which is what releases your file handle or
lock. And if a generator catches GeneratorExit and yields again, Python raises
RuntimeError: generator ignored GeneratorExit, because a generator being closed is not allowed
to refuse.
Note that this promptness is a CPython refcounting property. On PyPy the finally runs
whenever the GC gets to it, which is why explicit close() or a context manager is the portable
answer.
2.5 yield from
def inner():
yield 1
yield 2
return "done"
def outer():
result = yield from inner() # delegates, and captures the RETURN value
yield result
list(outer()) # [1, 2, 'done']
yield from does two things:
- Delegates iteration — everything
inneryields passes through, andsend/throw/closeare forwarded toinner. - Captures the return value as the value of the
yield fromexpression (PEP 380).
Note that 'done' is not yielded by inner. It is returned to outer, which chose to yield
it. That distinction is the question.
This is the mechanism that made coroutines possible before async/await existed —
asyncio's original @coroutine decorator used yield from for exactly this delegation, and
await is its direct descendant.
2.6 The traps
zip over-consumes.
a = iter([1, 2, 3, 4]); b = [10, 20]
list(zip(a, b)) # [(1,10), (2,20)]
next(a) # 4 <- 3 was pulled and DISCARDED
zip pulls from each iterator in order. It took 3 from a, then asked b for a third item,
got StopIteration, and stopped — throwing away the 3. This is the bug behind "my chunked
reader loses a record at the boundary". Use itertools.zip_longest, or buffer the pulled item.
itertools.tee buffers. tee must hold every item one branch has read that the other has
not. Drain one branch fully and you have materialized the whole stream — the exact thing you
used a lazy iterator to avoid. tee is only safe when branches advance roughly in lockstep.
Generators are not thread-safe. Two threads calling next() on one generator can interleave
and corrupt its frame; CPython raises ValueError: generator already executing if it catches
you. Wrap it in a lock, or give each thread its own.
Chapter 3: Async
3.1 The event loop, built from scratch
An event loop is much simpler than it sounds. Here is one:
import selectors, collections
class TinyLoop:
def __init__(self):
self._ready = collections.deque() # callbacks to run now
self._selector = selectors.DefaultSelector()
def call_soon(self, callback):
self._ready.append(callback)
def run_forever(self):
while True:
# 1. Run everything currently ready. Snapshot the count so
# callbacks scheduled during this pass wait for the next one.
for _ in range(len(self._ready)):
self._ready.popleft()()
# 2. Block in the OS until some fd is readable/writable
# (or a timer fires). This is the ONLY place we sleep.
for key, _events in self._selector.select(timeout=self._next_timeout()):
self.call_soon(key.data)
That is the whole idea: a queue of callbacks, plus one blocking call into the OS
(epoll/kqueue) that wakes when any registered file descriptor is ready.
Two consequences that answer most async questions:
- It is single-threaded. Concurrency comes from interleaving, not parallelism. Two coroutines never run simultaneously.
- A callback that does not return blocks everything. There is no preemption. If a callback runs for 2 seconds, no other callback runs, no fd is polled, no timer fires — for 2 seconds.
await is what lets a coroutine give control back: it suspends the coroutine, registers a
wake-up condition, and returns to the loop.
3.2 Coroutine, Task, Future
Three things people conflate:
Coroutine — what async def produces when called. Inert. Calling it runs no code.
async def work(): print("ran")
c = work() # nothing printed; a RuntimeWarning if never awaited
await c # NOW it runs
Task — a coroutine wrapped so the loop will step it. asyncio.create_task(coro) schedules
it to run concurrently; the coroutine starts making progress without you awaiting it.
Future — a placeholder for a result that will exist later. A Task is a subclass of Future.
The distinction that matters: await coro runs it now, inline, sequentially.
create_task(coro) starts it concurrently. So this is a common performance bug:
for url in urls:
await fetch(url) # SEQUENTIAL — no concurrency at all
results = await asyncio.gather(*(fetch(u) for u in urls)) # concurrent
Fire-and-forget tasks can be garbage collected. The loop holds only a weak reference to a task, so:
asyncio.create_task(background()) # BUG: may vanish mid-execution
The documented fix is to keep a strong reference:
tasks = set()
t = asyncio.create_task(background())
tasks.add(t)
t.add_done_callback(tasks.discard)
Or use a TaskGroup, which holds them for you.
3.3 Cancellation
task.cancel() schedules CancelledError to be raised at the point the task is suspended.
It does not stop the task immediately; the task must be at an await to receive it.
CancelledError inherits from BaseException, not Exception (since Python 3.8).
issubclass(asyncio.CancelledError, Exception) # False
issubclass(asyncio.CancelledError, BaseException) # True
Why this matters: a blanket except Exception: will not swallow cancellation. That is
deliberate — swallowing it would make the task uncancellable. So:
try:
await something()
except Exception:
handle() # correctly does NOT catch cancellation
except asyncio.CancelledError:
cleanup()
raise # <-- RE-RAISE. Not optional.
finally:
release()
Catching CancelledError without re-raising makes the task uncancellable, and then your
shutdown deadline becomes a hang. This is measurable: a task with a bare except CancelledError: pass in its loop keeps running after cancel() and after wait_for.
Cancellation is cooperative. A task in a tight CPU loop with no await cannot be cancelled
at all, because there is no suspension point at which to deliver the exception.
3.4 gather versus TaskGroup
This is the highest-value async question, because the difference is a bug, not a style preference.
async def boom(): raise ValueError("boom")
async def slow(): await asyncio.sleep(10); print("slow finished")
await asyncio.gather(boom(), slow())
What happens: gather (with the default return_exceptions=False) propagates the first
exception to the awaiter immediately — but it does not cancel the siblings. slow() keeps
running, orphaned, for the full 10 seconds. You have moved on; it has not.
That orphan is a resource leak: it holds connections, writes to stores you believed you had rolled back, and outlives the scope that created it. In a request handler it means a request that "failed" is still doing work.
async with asyncio.TaskGroup() as tg: # Python 3.11+
tg.create_task(boom())
tg.create_task(slow())
What happens: a failing child causes the remaining children to be cancelled, and the
group raises an ExceptionGroup you handle with except*:
try:
async with asyncio.TaskGroup() as tg:
...
except* ValueError as eg:
for exc in eg.exceptions:
log(exc)
This is structured concurrency: no task outlives its scope. Say it that way, and say that
gather's orphaning is a leak rather than a flavour.
gather | TaskGroup | |
|---|---|---|
| On child failure | first exception raised; siblings keep running | siblings cancelled |
| Exception type | the first one | ExceptionGroup |
| Multiple failures | only the first is seen | all of them |
| Task references | you hold them | the group holds them |
| Available | always | 3.11+ |
gather(..., return_exceptions=True) is still useful when you genuinely want all results
including failures and no cancellation — a health-check fan-out, for example. That is a
legitimate use; the orphaning case is not.
3.5 The blocking-call catastrophe
async def handler():
data = requests.get(url) # BLOCKING. The entire loop stops.
The loop is one thread running a callback queue. A blocking call means: no other coroutine runs, no fd is polled, no timer fires — for the whole duration. Every concurrent request's latency grows by that amount.
Measured with a 10 ms ticker running alongside a coroutine that calls time.sleep(0.15): the
largest gap between ticks is 162 ms instead of 10 ms. With await asyncio.to_thread(...)
instead, the largest gap is 14 ms.
The escape hatches:
await asyncio.to_thread(blocking_io, arg) # I/O-bound: a thread is fine
# (it releases the GIL during I/O)
loop = asyncio.get_running_loop()
await loop.run_in_executor(process_pool, cpu_heavy, arg) # CPU-bound: a process
This is the most common async production bug: one synchronous library call inside a request
handler, and the service's tail latency collapses under load. Enable asyncio debug mode
(PYTHONASYNCIODEBUG=1) and it will log callbacks that take too long.
3.6 Async generators and aclosing
async def rows():
conn = await connect()
try:
async for row in conn.stream():
yield row
finally:
await conn.close() # when does this run?
A synchronous generator's finally runs promptly via refcounting. An async generator's
cleanup needs to await, so it cannot run during garbage collection — there may be no running
loop. asyncio handles it via loop.shutdown_asyncgens(), at loop shutdown, which is
potentially much later.
So close them explicitly:
from contextlib import aclosing
async with aclosing(rows()) as stream:
async for row in stream:
if done: break # finally runs at __aexit__, right here
Without aclosing, an early break leaves the connection open until loop shutdown — which
under load is a connection leak.
Chapter 4: The GIL and Concurrency
4.1 What the GIL is and why it exists
The Global Interpreter Lock is a mutex that lets only one thread execute CPython bytecode at a time.
It exists because CPython's memory management is not thread-safe. Every object has a refcount (§1.3), incremented and decremented constantly. Making every refcount operation atomic would be slow — atomics cost tens of cycles, and refcounting is on every operation. One global lock was simpler and faster for the single-threaded case, which is most Python.
The GIL is released around blocking I/O and inside many C extensions (numpy, compression, crypto). That is why threads do help I/O-bound Python.
4.2 What it guarantees, precisely
Guaranteed: one thread executes bytecode at a time. Individual bytecode instructions, and C-level operations that never release the GIL, are effectively atomic.
Not guaranteed: that any sequence of your operations is atomic.
counter += 1
compiles to:
LOAD_GLOBAL counter
LOAD_CONST 1
BINARY_OP +=
STORE_GLOBAL counter
Load, add, store. A thread switch between load and store loses an update.
Whereas some_list.append(x) is one call into C that never releases the GIL, so it is atomic.
The compressed statement: the GIL protects interpreter internals, not your invariants.
4.3 Why the textbook race no longer reproduces
This is the section that separates people who have read about the GIL from people who have tested it.
Run the classic demo — eight threads each doing counter += 1 two hundred thousand times — on
CPython 3.13 and you will very likely lose zero updates. Most people conclude += is
atomic. It is not.
Why it does not reproduce: since CPython 3.10, the interpreter checks the eval breaker —
the flag that hands the GIL to another thread — only at specific instructions, principally
backward jumps and calls. It is not checked between every bytecode. In a tight loop, the check
lands on JUMP_BACKWARD, which is after the STORE_GLOBAL. So the load-add-store triple
happens to be uninterrupted every time.
It is uninterrupted by coincidence of code shape, not by guarantee. Change the shape and the race is immediate. Measured on the same machine, 8 threads × 200,000 increments:
| Code | Lost updates |
|---|---|
counter += 1 | 0 (0%) |
counter = add_one(counter) — a call between load and store | 50,589 (3.2%) |
counter += 1 where __add__ is a Python method | 974,016 (60.9%) |
list.append(i) | 0 — genuinely atomic |
The lesson, and it generalizes far beyond Python: "I ran it and it didn't lose anything" is not evidence of atomicity. Whether a read-modify-write is interrupted depends on interpreter version, code shape, and operand type — none of which is a contract you can rely on. If you need atomicity, take a lock.
4.4 Free-threaded Python, current status
The answer has four parts, and giving only one is the confidently-wrong version.
1. Which build. Two builds ship. The default still has the GIL. The free-threaded build
(python3.14t) does not. It is opt-in.
2. Which phase. PEP 703 designed the removal; PEP 779 defined the criteria for "supported". Phase I (3.13) was experimental. Phase II (3.14, October 2025) made it officially supported but still optional. Phase III — free-threading as the default — is not scheduled near-term.
3. What it costs. Reported at 3.14: single-threaded overhead ~5–10% (down from ~40% in 3.13's experimental build), memory ~15–20% higher, and roughly 4× speedup on suitable multi-threaded CPU-bound work.
4. What it does not fix. counter += 1 is still not atomic. Removing the GIL removes a
global lock, not your data races — arguably it makes them more likely to manifest, because
true parallelism widens the interleaving window. And C extensions must opt in; many have not.
Check at runtime with sys._is_gil_enabled().
4.5 The decision table
| Model | Wins on | The specific cost that makes it lose |
|---|---|---|
| asyncio | thousands of concurrent I/O waits; high fan-out RPC | one blocking call stalls everything; CPU work stalls everything; needs an async stack all the way down |
| Threads | blocking I/O through non-async libraries; moderate concurrency | no CPU parallelism under the GIL; ~8 MB stack each; shared-mutable-state bugs |
| Processes | CPU-bound work | serialization on every call; memory duplication; slow startup; no shared objects without explicit shared memory |
One line: async for waiting, processes for computing, threads for when the library gives you no choice.
Chapter 5: Memory
5.1 The allocator hierarchy
CPython does not call malloc for every object. Three layers:
- Arenas — 256 KB (1 MB on some builds) chunks obtained from the OS via
mmap. - Pools — 4 KB pages within an arena, each dedicated to one size class.
- Blocks — fixed-size slots within a pool. Size classes go up to 512 bytes in 8-byte steps.
Allocations ≤ 512 bytes come from pymalloc (pools and blocks); larger ones go straight to
malloc.
The consequence that answers a real production question: freeing objects does not necessarily return memory to the OS. An arena is only released when every pool in it is empty. One long-lived object can pin a 256 KB arena. So:
"I freed everything and RSS didn't drop" is expected behaviour, not a leak.
That is why tracemalloc (which tracks Python-level allocations) and RSS (which tracks OS-level
resident pages) disagree, and why fragmentation is a real concern in long-running Python
services.
5.2 __slots__, measured
By default every instance has a __dict__ — a hash table — for its attributes. Flexible, and
expensive: hash table overhead per instance, plus a pointer, plus poor locality.
__slots__ replaces it with fixed offsets in the object struct, like a C struct.
Measured over 200,000 instances of a 3-attribute class:
| Class | Memory | vs plain |
|---|---|---|
| Plain class | 19.9 MiB | — |
__slots__ = ("a","b","c") | 12.2 MiB | 38% smaller |
Subclass without its own __slots__ | 15.3 MiB | saving mostly lost |
Subclass with __slots__ = () | 12.2 MiB | saving kept |
What it breaks:
- Cannot add attributes not in the list —
AttributeError. - No
weakrefsupport unless you add'__weakref__'to the slots. - A subclass that does not declare
__slots__regains a__dict__, and most of the saving evaporates. Every class in the hierarchy must declare it. - Incompatible with multiple inheritance from two classes that both have non-empty slots.
The third bullet is the trap, and it is why "we added __slots__" without a measurement is
not an answer. Note also that modern CPython has key-sharing dictionaries (PEP 412), which
already share the key layout between instances of a class — so the saving is smaller than it was
pre-3.3, which is another reason to measure rather than assume.
5.3 Why getsizeof lies
sys.getsizeof returns the object's own footprint. It does not follow references.
Measured, a list of 50,000 strings:
| Measurement | Result |
|---|---|
sys.getsizeof(list) | 434 KiB — the header plus the pointer array |
+ sum(getsizeof(s) for s in list) | 3,510 KiB — 8× larger |
tracemalloc peak | 3,510 KiB — what it actually cost |
So the honest answer to "how much memory is this costing?" is:
tracemalloc— attributes real allocations to source lines. The right tool.pympler.asizeof— a deep size, following references.- And remember RSS ≠ live bytes, because of §5.1.
5.4 The buffer protocol and memoryview
Slicing bytes copies. For large buffers that is the whole cost.
data = bytearray(32 * 1024 * 1024) # 32 MiB
bytes(data)[:16*1024*1024] # allocates 16 MiB — a copy
memoryview(data)[:16*1024*1024] # allocates ~0 — a VIEW
Measured: the copy allocates 16.00 MiB; the memoryview allocates 0.0003 MiB.
A memoryview exposes the buffer protocol — a C-level interface for sharing memory without
copying. Writes through the view mutate the original.
This is how you parse a 2 GB frame without a 2 GB copy, and it is the right answer to "how would
you avoid the copy here". It is also what makes numpy, struct.unpack_from, and
socket.recv_into efficient.
5.5 Interning
a, b = 256, 256
a is b # True — small ints (-5..256) are cached singletons
c, d = 257, 257
c is d # True — same code object, constant-folded to ONE constant
e, f = int("257"), int("257")
e is f # False — computed at runtime
e == f # True
Three different mechanisms produce these three answers: small-int caching, compile-time constant
folding within one code object, and runtime construction. Short identifier-like strings are also
interned automatically, and sys.intern does it explicitly.
The rule: never use is for value comparison. Whether two equal values are the same object
depends on the compiler's constant folding, which is not part of the language. is is for
identity — x is None, sentinel checks — and nothing else.
Chapter 6: The Data Model
6.1 Attribute lookup, in order
instance.x resolves in this order:
- Data descriptor on
type(instance)or its MRO — has__get__and (__set__or__delete__) instance.__dict__['x']- Non-data descriptor on the type — has only
__get__ - Class attribute on the type or its MRO
__getattr__on the type — only if everything above raisedAttributeError
Verified: with both a data descriptor and an instance-dict entry named the same, the data descriptor wins. With a non-data descriptor and an instance-dict entry, the instance dict wins.
6.2 Descriptors
A descriptor is an object defining __get__, __set__, or __delete__, used as a class
attribute.
- Data descriptor — defines
__set__or__delete__. Sits ahead of the instance dict. - Non-data descriptor — only
__get__. Sits behind the instance dict.
That single distinction explains two things people usually memorize separately:
class Account:
@property
def balance(self): return self._balance
def describe(self): return "the real method"
a = Account()
a.balance = 5 # AttributeError: property has no setter
a.describe = lambda: "patched"
a.describe() # "patched" — instance dict won
property is a data descriptor, so it outranks the instance dict and cannot be shadowed. A
plain function is a non-data descriptor, so the instance dict outranks it and monkeypatching
works.
And it explains why methods work at all. A function stored on a class is a non-data
descriptor whose __get__ returns a bound method — a partial application of the function to
the instance. self is not magic; it is the descriptor protocol.
6.3 __getattr__ versus __getattribute__
__getattribute__ | __getattr__ | |
|---|---|---|
| Called | for every attribute access | only when normal lookup raised AttributeError |
| Cost | on the hot path, always | only on misses — free otherwise |
| Use for | intercepting everything (rare) | proxies, lazy attributes (common) |
The classic bug:
class Recursive:
def __getattribute__(self, name):
return self.__dict__[name] # self.__dict__ calls __getattribute__ again
RecursionError. The fix is to delegate to the base implementation:
return object.__getattribute__(self, name)
Prefer __getattr__ unless you genuinely must intercept every access — it runs only on misses,
so it costs nothing on the fast path.
6.4 MRO and what super actually does
super() does not mean "the parent class". It means "the next class in the MRO of
type(self)" — which depends on the instance, not on where the code is written.
class Base:
def go(self): order.append("Base")
class Left(Base):
def go(self): order.append("Left"); super().go()
class Right(Base):
def go(self): order.append("Right"); super().go()
class Diamond(Left, Right):
def go(self): order.append("Diamond"); super().go()
Diamond().go()
# MRO: Diamond -> Left -> Right -> Base -> object
# order: Diamond -> Left -> Right -> Base
Left.go's super() reached Right, not Base — even though Left's only base is Base.
Because the MRO is computed from Diamond.
The MRO is computed by C3 linearization, which guarantees: a class precedes its bases,
declaration order among bases is preserved, and the result is monotonic. If no consistent
linearization exists, the class statement raises TypeError at definition time.
The practical consequence: cooperative multiple inheritance requires every class in the chain
to call super(). One class that calls Base.go(self) directly instead breaks the chain and
silently skips everything after it in the MRO.
6.5 Context managers
with resource() as r:
body()
is approximately:
mgr = resource()
r = type(mgr).__enter__(mgr)
try:
body()
except BaseException as exc:
if not type(mgr).__exit__(mgr, type(exc), exc, exc.__traceback__):
raise
else:
type(mgr).__exit__(mgr, None, None, None)
The graded detail: __exit__ returning a truthy value suppresses the exception.
Returning None (the default) lets it propagate.
def __exit__(self, exc_type, exc, tb):
return True # swallows EVERY exception in the block
A bare return True in __exit__ is how a context manager silently eats every bug inside it,
and it should be treated as a review red flag. contextlib.suppress does exactly this, but
narrowly and explicitly, which is the difference.
The Justification Drill
The way to actually use this track. After solving any Track A problem, answer these out loud about your own code:
- Why a generator here rather than returning a list? What does it cost?
- What is the memory footprint of this structure, and how would you measure it rather than guess?
- What breaks if two threads call this? Which specific line?
- If this were async, where is the blocking call, and what would it stall?
- Who cleans this up, and when exactly? What if the consumer breaks early?
- Why this dunder rather than that one? What does the lookup order say?
- What is the complexity, and which CPython implementation detail makes it so?
Every one of those is a real interview follow-up, and answering them about code you just wrote is what converts this material from trivia into fluency.
Run the five experiment scripts in experiments/ and predict every output
before running. Every mismatch is a genuine gap and goes into
../../review/ at the 1-day interval. Confident-wrong answers go to
the front of the queue.
References
- Ramalho, L. Fluent Python, 2nd ed. — Ch. 17 iterators/generators, Ch. 19 concurrency models, Ch. 21 async, Ch. 23 descriptors. The single best source for this track
- Slatkin, B. Effective Python, 3rd ed. — the items on generators, concurrency and memory
- Beazley, D. Generators: The Final Frontier. https://www.dabeaz.com/finalgenerator/ — the definitive generator talk
- Beazley, D. Understanding the Python GIL. https://www.dabeaz.com/GIL/
- CPython devguide — Garbage Collector Design. https://devguide.python.org/internals/garbage-collector/
- CPython docs — Descriptor HowTo Guide. https://docs.python.org/3/howto/descriptor.html
- CPython docs — Python support for free threading. https://docs.python.org/3/howto/free-threading-python.html
- PEP 380 — Delegating to a Subgenerator · PEP 412 — Key-Sharing Dictionary · PEP 442 — Safe Object Finalization · PEP 479 — StopIteration handling · PEP 492/525 — Coroutines, Async Generators · PEP 703 — Making the GIL Optional · PEP 779 — Criteria for supported free-threading
- Python Free-Threading Guide. https://py-free-threading.github.io/
- Simionato, M. The Python 2.3 Method Resolution Order. https://www.python.org/download/releases/2.3/mro/ — C3 explained properly
In this repo
QUIZBANK.md— 150 questions with full answers, feeding the spaced-repetition queueexperiments/— five runnable scripts proving every claim aboveREADME.md— Track B drills, failure modes, rubric../coding/WARMUP.md— the implementations these mechanisms underpin