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

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, then gc.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__":

  1. Finalization order within a cycle is undefined__del__ may run on an object whose peers are already finalized, so touching them is unsafe.
  2. Exceptions inside __del__ are swallowed and printed to stderr. You cannot handle them.
  3. Resurrection: __del__ can store self somewhere and revive the object.
  4. 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

IterableIterator
Implements__iter____iter__ and __next__
__iter__ returnsa new iteratorself
Holds positionnoyes
Reusableyesno — 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:

  1. The generator does not catch it → it propagates out of throw() and the generator closes.
  2. The generator catches it and yields again → throw() returns that value.
  3. The generator catches it and returns → throw() raises StopIteration.

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:

  1. Delegates iteration — everything inner yields passes through, and send/throw/close are forwarded to inner.
  2. Captures the return value as the value of the yield from expression (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.

gatherTaskGroup
On child failurefirst exception raised; siblings keep runningsiblings cancelled
Exception typethe first oneExceptionGroup
Multiple failuresonly the first is seenall of them
Task referencesyou hold themthe group holds them
Availablealways3.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:

CodeLost updates
counter += 10 (0%)
counter = add_one(counter) — a call between load and store50,589 (3.2%)
counter += 1 where __add__ is a Python method974,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

ModelWins onThe specific cost that makes it lose
asynciothousands of concurrent I/O waits; high fan-out RPCone blocking call stalls everything; CPU work stalls everything; needs an async stack all the way down
Threadsblocking I/O through non-async libraries; moderate concurrencyno CPU parallelism under the GIL; ~8 MB stack each; shared-mutable-state bugs
ProcessesCPU-bound workserialization 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:

  1. Arenas — 256 KB (1 MB on some builds) chunks obtained from the OS via mmap.
  2. Pools — 4 KB pages within an arena, each dedicated to one size class.
  3. 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:

ClassMemoryvs plain
Plain class19.9 MiB
__slots__ = ("a","b","c")12.2 MiB38% smaller
Subclass without its own __slots__15.3 MiBsaving mostly lost
Subclass with __slots__ = ()12.2 MiBsaving kept

What it breaks:

  • Cannot add attributes not in the list — AttributeError.
  • No weakref support 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:

MeasurementResult
sys.getsizeof(list)434 KiB — the header plus the pointer array
+ sum(getsizeof(s) for s in list)3,510 KiB — 8× larger
tracemalloc peak3,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:

  1. Data descriptor on type(instance) or its MRO — has __get__ and (__set__ or __delete__)
  2. instance.__dict__['x']
  3. Non-data descriptor on the type — has only __get__
  4. Class attribute on the type or its MRO
  5. __getattr__ on the type — only if everything above raised AttributeError

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__
Calledfor every attribute accessonly when normal lookup raised AttributeError
Coston the hot path, alwaysonly on misses — free otherwise
Use forintercepting 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:

  1. Why a generator here rather than returning a list? What does it cost?
  2. What is the memory footprint of this structure, and how would you measure it rather than guess?
  3. What breaks if two threads call this? Which specific line?
  4. If this were async, where is the blocking call, and what would it stall?
  5. Who cleans this up, and when exactly? What if the consumer breaks early?
  6. Why this dunder rather than that one? What does the lookup order say?
  7. 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

In this repo

  • QUIZBANK.md — 150 questions with full answers, feeding the spaced-repetition queue
  • experiments/ — five runnable scripts proving every claim above
  • README.md — Track B drills, failure modes, rubric
  • ../coding/WARMUP.md — the implementations these mechanisms underpin