Chapter 4

Python core and CPython internals

Python objects, the descriptor protocol, GIL, reference counting, and bytecode.

Python core and CPython internals

The Python half of the language deep dive: what an object actually is, how attribute lookup and the descriptor protocol produce property and bound methods, what the GIL does and does not protect, how reference counting and the cyclic collector divide the work, and what the bytecode tells you about why one idiom is faster than another. Everything runnable was executed on CPython 3.11.15 on a 2-core container, and the pasted output is real. Features from 3.12/3.13/3.14 are described in prose with an explicit version label.

Table of contents


Gap note. Traps this chapter does not cover — for/else, name mangling, the exception variable unbinding, Exception vs BaseException, generator finally timing, Decimal, and the match bare-name capture — are closed in 20 §5 and 20 §6, all with executed output.

1. The object model

Every value is a PyObject*. The header is a refcount plus a type pointer; variable-size objects (str, tuple, list, int) add a length. That single fact explains most of Python’s behaviour and most of its memory footprint:

sys.getsizeof measured on CPython 3.11 (64-bit)
  list()           56 bytes        tuple()          40 bytes
  list of 3        88 bytes        tuple of 3       64 bytes      <- tuples have no spare capacity
  dict()           64 bytes        set()           216 bytes
  dict 1 key      184 bytes        set 1           216 bytes      <- sets pre-allocate 8 slots
  str ""           49 bytes        str "abc"        52 bytes      <- 1 byte/char for ASCII (compact)
  int 0            28 bytes        int 1<<70        36 bytes      <- 30 bits per "digit"

An empty list costs 56 bytes and each element adds 8 more (a pointer), so a list of a million small integers is ~8 MB of pointers plus ~28 MB of integer objects. That is the number to quote when someone asks why you would reach for array, bytes or NumPy.

1.1 Names, objects, and bindings

Python has no variables in the C sense. A name is an entry in a namespace that points at an object. Assignment rebinds the name; it never copies and never writes through.

a = [1, 2, 3]
b = a               # two names, ONE object
b.append(4)         # mutates the object
print(a)            # [1, 2, 3, 4]
b = [9]             # rebinds b only
print(a)            # [1, 2, 3, 4]

Argument passing is the same mechanism, usually called call by object reference or call by sharing: the callee gets a new name bound to the caller’s object. Mutating it is visible to the caller; rebinding it is not.

def f(xs, y):
    xs.append(1)     # visible outside
    y = 99           # invisible outside

1.2 is vs ==, and the caching that confuses everyone

is compares identity (the pointer), == calls __eq__. Use is only for singletons: None, True, False, NotImplemented, Ellipsis, and sentinels you created yourself.

CPython caches small integers in [-5, 256]. To see the real boundary you have to defeat the compiler’s constant folding, because two identical literals in the same code object are the same constant:

int('-6')  is int('-6')  -> False
int('-5')  is int('-5')  -> True
int('0')   is int('0')   -> True
int('256') is int('256') -> True
int('257') is int('257') -> False

And the case that trips people up in a REPL:

inside one function:  a = 257; b = 257; a is b   -> True   (co_consts is (None, 257) — ONE object)
same code object   :  x = 1000; y = 1000; x is y -> True
different code objects (two separate compiles)   -> False

So 257 is 257 being True is constant deduplication, not the small-int cache. Being able to separate those two mechanisms is the difference between reciting trivia and understanding it.

String interning follows a similar rule: string literals that look like identifiers are interned at compile time, and (in 3.11) literals in the same code object are deduplicated regardless. Strings built at runtime are not interned unless you call sys.intern:

"hello" is "hello"                 -> True
"hello world!" is "hello world!"   -> True    (same code object)
"".join(["he","llo"]) is "hello"   -> False
sys.intern("".join(...)) is "hello"-> True

sys.intern is a real optimization for a dictionary keyed by many repeated runtime-built strings: it makes equality checks pointer comparisons.

1.3 Mutability and the two traps

ImmutableMutable
int, float, complex, bool, str, bytes, tuple, frozenset, rangelist, dict, set, bytearray, most objects

Trap 1 — mutable default arguments. Defaults are evaluated once, at function definition.

def bad(item, into=[]):        # the list is created ONCE
    into.append(item)
    return into
bad(1); bad(2)                 # [1, 2] — shared across calls

def good(item, into=None):
    into = [] if into is None else into
    into.append(item)
    return into

The same trap applies to datetime.now() as a default, and to any mutable class attribute used as per-instance state.

Trap 2 — a “immutable” tuple containing a mutable object.

t = ([1], 2)
t[0].append(9)      # legal: the tuple's references did not change
# t[0] = [1]        # TypeError
hash(t)             # TypeError: unhashable type 'list'

1.4 Copying

import copy
shallow = copy.copy(obj)      # new outer container, same inner references
deep    = copy.deepcopy(obj)  # recursive, memoizes to handle cycles and shared references
# idioms: list(xs), xs[:], dict(d), d.copy(), set(s) are all SHALLOW

deepcopy respects __deepcopy__/__copy__, uses __reduce_ex__ as a fallback, and keeps a memo dict so a graph with shared nodes stays shared (rather than duplicating them) and cycles do not recurse forever. It is also slow — for plain data, json.loads(json.dumps(x)) or a hand-written recursive copy is often 5–10x faster.


2. The data model (dunder protocols)

The data model is Python’s interface system: implement the right dunders and the syntax works.

2.1 Lifecycle

class Immutable:
    def __new__(cls, value):          # allocation; runs BEFORE __init__, gets the class
        obj = super().__new__(cls)
        object.__setattr__(obj, '_value', value)
        return obj
    def __init__(self, value): pass   # initialization; runs on the object __new__ returned
    def __setattr__(self, k, v): raise AttributeError('immutable')
    def __del__(self): pass           # NOT a destructor you can rely on

__new__ is what you override for immutable subclasses (int, str, tuple), for singletons, and for factory dispatch. If __new__ returns something that is not an instance of cls, __init__ is not called. __del__ runs when the refcount hits zero — which may be never (cycles pre-3.4 semantics, interpreter shutdown), may be on a different thread, and may resurrect the object. Use a context manager or weakref.finalize for cleanup, never __del__.

2.2 Representation

class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __repr__(self): return f'Point(x={self.x!r}, y={self.y!r})'   # unambiguous, for developers
    def __str__(self): return f'({self.x}, {self.y})'                  # readable, for users
    def __format__(self, spec): return format(str(self), spec)

__repr__ should ideally be valid Python that reconstructs the object. If you define only __repr__, str() falls back to it — so if you write one, write __repr__.

2.3 Comparison and ordering

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, *parts): self.parts = parts
    def __eq__(self, other):
        if not isinstance(other, Version): return NotImplemented
        return self.parts == other.parts
    def __lt__(self, other):
        if not isinstance(other, Version): return NotImplemented
        return self.parts < other.parts
    def __hash__(self): return hash(self.parts)     # MUST redefine: defining __eq__ sets __hash__ = None

Returning NotImplemented (not raising, not returning False) is what lets Python try the reflected operation on the other operand. @total_ordering fills in <=, >, >= from __eq__ and __lt__ at the cost of an extra call per comparison.

2.4 The hash/eq contract

Three rules, and violating any of them corrupts your dicts:

  1. If a == b then hash(a) == hash(b).
  2. A hash must not change while the object is in a hash-based container.
  3. Defining __eq__ without __hash__ sets __hash__ = None, making instances unhashable — this is deliberate, because a mutable object with value equality is a footgun.
class Bad:
    def __init__(self, v): self.v = v
    def __eq__(self, o): return isinstance(o, Bad) and self.v == o.v
    def __hash__(self): return hash(self.v)      # hash depends on MUTABLE state

b = Bad(1); s = {b}
b.v = 2                    # hash changed while in the set
print(b in s)              # False — the object is lost, in a bucket it no longer hashes to
print(len(s), list(s)[0].v)  # 1 2  — it is still in the set, just unreachable by lookup

The fix is to hash only immutable fields, or to make the object immutable (@dataclass(frozen=True) gives you __hash__ for free).

2.5 Container protocol

DunderEnables
__len__len(x), and truthiness when __bool__ is absent
__getitem__x[k], slicing, and iteration as a fallback (the old protocol)
__setitem__, __delitem__x[k] = v, del x[k]
__contains__in (falls back to iteration)
__iter__, __next__for, unpacking, comprehensions
__reversed__reversed(x)
__missing__only on dict subclasses: called by __getitem__ on a miss (this is how defaultdict works)
class Grid:
    def __init__(self, w, h): self.w, self.h, self._d = w, h, [0] * (w * h)
    def __getitem__(self, pos):
        r, c = pos                      # enables grid[1, 2]
        return self._d[r * self.w + c]
    def __setitem__(self, pos, v):
        r, c = pos; self._d[r * self.w + c] = v
    def __len__(self): return self.w * self.h
    def __iter__(self): return iter(self._d)

Inheriting from collections.abc.Sequence/Mapping/MutableMapping gives you the derived methods (__contains__, index, count, get, keys, items, pop, update, …) from just two or three abstract ones — worth naming in an interview as the “mixin” answer.

2.6 Context managers

class Transaction:
    def __enter__(self):
        self.conn = connect(); self.conn.begin(); return self.conn
    def __exit__(self, exc_type, exc, tb):
        if exc_type is None: self.conn.commit()
        else:                self.conn.rollback()
        self.conn.close()
        return False          # False/None -> re-raise; True -> SUPPRESS the exception

from contextlib import contextmanager, ExitStack, suppress
@contextmanager
def timing(label):
    import time; t = time.perf_counter()
    try:
        yield
    finally:
        print(f'{label}: {time.perf_counter() - t:.4f}s')

# dynamic number of context managers
with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]

with suppress(FileNotFoundError):
    os.remove(path)

Returning a truthy value from __exit__ swallows the exception. That is how suppress works, and it is the answer to “how do you write a context manager that ignores an error”.

2.7 __slots__

__slots__ replaces the per-instance __dict__ with a fixed array of descriptors.

Plain   sys.getsizeof(obj) = 56 + __dict__ 296
Slotted sys.getsizeof(obj) = 48 (no __dict__)

200,000 x Plain    =  25.62 MB  (128 bytes/obj)
200,000 x Slotted  =  17.62 MB  ( 88 bytes/obj)     <- 31% less

attribute read: dict 0.054s  slots 0.054s  (0.99x)  <- no speed difference in 3.11

Two honest conclusions from the measurement. Memory: real, ~31% for a two-field object, and much more for objects with many small instances. Speed: no measurable difference on 3.11, because the specializing adaptive interpreter (PEP 659) specializes LOAD_ATTR on instance dicts too. The old “slots are faster” advice was true for 3.8 and is largely stale now; lead with memory.

Costs: no __dict__ (so no ad-hoc attributes), no weak references unless you add '__weakref__', multiple inheritance from two slotted classes with overlapping slots is an error, and every subclass must declare __slots__ too or it regains a __dict__. @dataclass(slots=True) (3.10+) does it for you.


3. Attribute lookup, descriptors, and the MRO

3.1 The lookup order

graph TD
    A["obj.x accessed"] --> B{"Found in type(obj).__mro__?"}
    B -- No --> C["Raise AttributeError<br/>(triggers __getattr__ if defined)"]
    B -- Yes --> D{"Data descriptor?<br/>(defines __set__ or __delete__)"}
    D -- Yes --> E["return descriptor.__get__(obj, type(obj))<br/>data descriptor wins"]
    D -- No --> F{"'x' in obj.__dict__?"}
    F -- Yes --> G["return obj.__dict__['x']"]
    F -- No --> H{"Non-data descriptor?<br/>(only __get__)"}
    H -- Yes --> I["return descriptor.__get__(obj, type(obj))"]
    H -- No --> J["return plain class attribute"]

obj.x invokes type(obj).__getattribute__(obj, 'x'), which does, in order:

1. Walk type(obj).__mro__ looking for 'x'.
2. If found AND it is a DATA descriptor (defines __set__ or __delete__):
       return descriptor.__get__(obj, type(obj))          <- data descriptors WIN over the instance dict
3. Look in obj.__dict__; if present, return it.
4. If the class attribute from step 1 is a NON-DATA descriptor (only __get__):
       return descriptor.__get__(obj, type(obj))
5. If it is a plain class attribute, return it.
6. Otherwise raise AttributeError -> which triggers type(obj).__getattr__ if defined.

The ordering in steps 2–4 is the whole reason property can shadow an instance attribute while a plain method cannot. Interviewers love this because it is precise and testable.

__getattribute__ runs for every access (override it and you will slow everything down and risk infinite recursion — always delegate with super().__getattribute__). __getattr__ runs only on failure, which makes it the cheap hook for proxies and lazy loading.

3.2 Descriptors

A descriptor is any object defining __get__, __set__, or __delete__, stored as a class attribute.

class Typed:
    """A data descriptor that validates on assignment."""
    def __set_name__(self, owner, name):      # 3.6+: told its own attribute name
        self.name = '_' + name
    def __init__(self, expected): self.expected = expected
    def __get__(self, obj, objtype=None):
        if obj is None: return self           # accessed on the class
        return getattr(obj, self.name)
    def __set__(self, obj, value):
        if not isinstance(value, self.expected):
            raise TypeError(f'expected {self.expected.__name__}, got {type(value).__name__}')
        setattr(obj, self.name, value)

class Account:
    balance = Typed(int)
    owner = Typed(str)
    def __init__(self, owner, balance): self.owner, self.balance = owner, balance

a = Account('ana', 100)
# Account('ana', 'oops')     -> TypeError: expected int, got str

Everything in the “how does Python work” bucket is a descriptor:

ThingKindWhy
a plain functionnon-data descriptorits __get__ returns a bound method
propertydata descriptorhas __set__, so it beats the instance dict
classmethod / staticmethodnon-data__get__ returns a bound-to-class / plain function
__slots__ membersdataarray-slot getters and setters
functools.cached_propertynon-datacomputes once, then writes into __dict__ so it is never called again

Implementing the two most instructive ones from scratch:

class my_property:                     # a data descriptor
    def __init__(self, fget=None, fset=None):
        self.fget, self.fset = fget, fset
        self.__doc__ = getattr(fget, '__doc__', None)
    def __get__(self, obj, objtype=None):
        if obj is None: return self
        if self.fget is None: raise AttributeError('unreadable')
        return self.fget(obj)
    def __set__(self, obj, value):
        if self.fset is None: raise AttributeError('can\'t set attribute')
        self.fset(obj, value)
    def setter(self, fset): return type(self)(self.fget, fset)

class my_classmethod:                  # a non-data descriptor
    def __init__(self, f): self.f = f
    def __get__(self, obj, objtype=None):
        from functools import partial
        return partial(self.f, objtype if objtype is not None else type(obj))

class Demo:
    _v = 1
    @my_property
    def v(self): return self._v
    @v.setter
    def v(self, x): self._v = x * 2
    @my_classmethod
    def make(cls): return cls()

d = Demo(); d.v = 5
print(d.v, isinstance(Demo.make(), Demo))     # 10 True

cached_property being a non-data descriptor is the key to its speed: after the first call it writes the value into obj.__dict__, and because non-data descriptors lose to the instance dict, subsequent lookups never reach the descriptor at all.

3.3 The MRO and cooperative super()

Python linearizes multiple inheritance with C3: preserve each class’s own order, preserve the order of bases, and never place a class before its subclass.

class A:
    def who(self): return 'A'
class B(A):
    def who(self): return 'B->' + super().who()
class C(A):
    def who(self): return 'C->' + super().who()
class D(B, C):
    def who(self): return 'D->' + super().who()
['D', 'B', 'C', 'A', 'object']
D().who() = D->B->C->A   <- B's super() is C, not A

This is the single most important thing to understand about super(): super() is not “the parent class”, it is “the next class in the MRO of type(self). B.who calls C.who even though C is not B’s base, because in D’s MRO, C follows B. That is what makes cooperative multiple inheritance (and mixins) work, and why every cooperating method must call super() and accept **kwargs.

super() with no arguments is compiler magic: it uses the __class__ cell and the first positional argument, which is why it only works lexically inside a class body.

C3 fails (raises TypeError at class creation) when no consistent order exists, e.g. class X(A, B) and class Y(B, A) then class Z(X, Y).


4. Iterators, generators, coroutines

4.1 The protocol

An iterable has __iter__; an iterator has __next__ and returns itself from __iter__. for x in it is sugar for it = iter(obj) then repeated next(it) until StopIteration.

class Countdown:
    def __init__(self, n): self.n = n
    def __iter__(self): return self          # NOTE: single-pass, because self IS the iterator
    def __next__(self):
        if self.n <= 0: raise StopIteration
        self.n -= 1
        return self.n + 1

class Countdown2:                            # multi-pass: a fresh iterator per __iter__
    def __init__(self, n): self.n = n
    def __iter__(self):
        for i in range(self.n, 0, -1): yield i

The difference between those two classes is a classic interview question. list(c) twice gives [3,2,1] then [] for the first, and [3,2,1] twice for the second.

4.2 Generators

def fib():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

from itertools import islice
print(list(islice(fib(), 10)))     # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Generators are coroutines with a narrow interface:

  • gen.send(v) — resume; the yield expression evaluates to v. You must next() (prime) first.
  • gen.throw(exc) — raise at the suspension point, catchable inside.
  • gen.close() — raise GeneratorExit there; finally blocks run.
  • yield from it — delegate, forwarding send/throw and evaluating to the sub-generator’s return value.
def running_average():
    total = count = 0
    while True:
        x = yield (total / count if count else None)
        total += x; count += 1

avg = running_average(); next(avg)
print(avg.send(10), avg.send(20), avg.send(30))     # 10.0 15.0 20.0

def inner():
    yield 1; yield 2
    return 'inner-done'
def outer():
    result = yield from inner()
    yield result
print(list(outer()))    # [1, 2, 'inner-done']

Memory: a generator holds one frame regardless of the sequence length.

import sys
print(sys.getsizeof([i for i in range(1_000_000)]))   # ~8.4 MB
print(sys.getsizeof((i for i in range(1_000_000))))   # 208 bytes

The pipeline idiom — each stage is lazy, nothing is materialized:

def read(path):
    with open(path) as f:
        for line in f: yield line.rstrip('\n')

def parse(lines):
    for l in lines:
        if l and not l.startswith('#'): yield l.split('\t')

def project(rows, i):
    for r in rows: yield r[i]

total = sum(int(v) for v in project(parse(read('data.tsv')), 2))

4.3 asyncio

async def produces a coroutine object; awaiting it runs it. A Task is a coroutine scheduled on the loop. A Future is a placeholder for a result.

import asyncio, time

async def fetch(name, delay):
    await asyncio.sleep(delay)          # yields to the loop; time.sleep would BLOCK it
    return f'{name} after {delay}s'

async def main():
    t = time.perf_counter()

    # sequential: 0.3s
    a = await fetch('a', 0.1); b = await fetch('b', 0.2)

    # concurrent: 0.2s
    results = await asyncio.gather(fetch('c', 0.1), fetch('d', 0.2))

    # as_completed: handle results in finishing order
    for fut in asyncio.as_completed([fetch('e', 0.2), fetch('f', 0.05)]):
        print('  done:', await fut)

    # TaskGroup (3.11+): structured concurrency — cancels siblings if one fails
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(fetch('g', 0.05))
        t2 = tg.create_task(fetch('h', 0.05))
    print('  taskgroup:', t1.result(), '|', t2.result())

    # timeout (3.11+)
    try:
        async with asyncio.timeout(0.05):
            await fetch('slow', 1.0)
    except TimeoutError:
        print('  timed out')

    # move a blocking call off the loop
    await asyncio.to_thread(time.sleep, 0.01)
    print(f'  total {time.perf_counter()-t:.2f}s')

asyncio.run(main())
  done: f after 0.05s
  done: e after 0.2s
  taskgroup: g after 0.05s | h after 0.05s
  timed out
  total 0.72s
CombinatorBehaviour on errorOrder of results
gather(*aws)first exception propagates, siblings keep runningargument order
gather(*aws, return_exceptions=True)exceptions become resultsargument order
as_completed(aws)you see each as it settlescompletion order
TaskGroup (3.11+)cancels all siblings, raises ExceptionGroupyou hold the tasks
wait(aws, return_when=...)never raises; returns (done, pending)sets, not ordered

Cancellation is delivered as asyncio.CancelledError raised at the next await. It inherits from BaseException (since 3.8) so a bare except Exception will not swallow it. asyncio.shield protects an awaitable from cancellation propagating inward.

The two mistakes that matter. Any blocking call (time.sleep, requests.get, a heavy CPU loop, a synchronous DB driver) stalls the entire loop — all tasks, not just yours. And creating a task without holding a reference lets it be garbage collected mid-flight; keep a set of tasks and discard on done.


5. Functions, closures, decorators

5.1 Closures and nonlocal

def counter():
    n = 0
    def inc():
        nonlocal n            # without this, n = n + 1 makes n local -> UnboundLocalError
        n += 1
        return n
    return inc

c = counter(); print(c(), c(), c())          # 1 2 3
print(c.__closure__[0].cell_contents)        # 3 — the cell is visible

The late-binding trap. A closure captures the variable, not its value at creation time.

fs = [lambda: i for i in range(3)]
print([f() for f in fs])                     # [2, 2, 2]  — all see the final i

fs = [lambda i=i: i for i in range(3)]       # fix 1: default argument, bound at definition
print([f() for f in fs])                     # [0, 1, 2]

from functools import partial
fs = [partial(lambda i: i, i) for i in range(3)]   # fix 2

This is the exact analogue of JavaScript’s for (var i) bug; see JS core 2.4.

5.2 Decorators

import functools, time, random

def timed(fn):
    @functools.wraps(fn)                     # preserves __name__, __doc__, __wrapped__, __module__
    def wrapper(*a, **kw):
        t = time.perf_counter()
        try:    return fn(*a, **kw)
        finally: print(f'{fn.__name__}: {time.perf_counter()-t:.4f}s')
    return wrapper

def retry(attempts=3, base=0.1, exceptions=(Exception,)):
    """A decorator WITH arguments: three levels of nesting."""
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*a, **kw):
            for i in range(attempts):
                try: return fn(*a, **kw)
                except exceptions:
                    if i == attempts - 1: raise
                    time.sleep(random.random() * base * 2 ** i)     # full jitter
        return wrapper
    return decorator

@retry(attempts=5, exceptions=(ConnectionError,))
@timed
def flaky(): ...

Stacking order: decorators apply bottom-up, so @retry wraps the result of @timed, and retry’s wrapper is the outermost — meaning the timing prints once per attempt. Reversing the two would time the whole retry loop instead. Being able to reason about this is the point of the question.

Class decorators and decorating methods:

def singleton(cls):
    instances = {}
    @functools.wraps(cls)
    def get(*a, **kw):
        if cls not in instances: instances[cls] = cls(*a, **kw)
        return instances[cls]
    return get

def register(registry):
    def deco(cls): registry[cls.__name__] = cls; return cls
    return deco

class Service:
    @staticmethod
    def helper(): ...
    @classmethod
    def create(cls): return cls()
    @property
    def ready(self): return True
    @functools.cached_property
    def expensive(self): return sum(range(10**6))    # computed once per instance

Note the ordering rule for @property + @staticmethod-style stacking: the descriptor decorator must be outermost (applied last), because it needs to wrap the plain function.

5.3 functools worth knowing cold

from functools import (partial, reduce, lru_cache, cache, cached_property,
                       singledispatch, wraps, cmp_to_key, total_ordering)

@cache                               # 3.9+: unbounded lru_cache, slightly faster
def fib(n): return n if n < 2 else fib(n-1) + fib(n-2)
fib(200)                             # instant; without the cache this is 2^200 calls
print(fib.cache_info())              # CacheInfo(hits=198, misses=201, maxsize=None, currsize=201)

@lru_cache(maxsize=1024)
def geo(lat, lon): ...

@singledispatch                      # type-based dispatch on the FIRST argument
def encode(x): raise TypeError(type(x))
@encode.register
def _(x: int): return f'i:{x}'
@encode.register
def _(x: list): return '[' + ','.join(map(encode, x)) + ']'

lru_cache gotchas an interviewer will probe: arguments must be hashable (no lists or dicts); it holds strong references to arguments and results, so caching methods keeps self alive forever (leak) — use cache on a module-level function taking the id, or cached_property, or a WeakValueDictionary; f(1) and f(x=1) are different cache keys; and it is thread-safe but not process-shared.


6. Concurrency and the GIL

6.1 What the GIL is

The Global Interpreter Lock is a single mutex that a thread must hold to execute Python bytecode. It exists because CPython’s reference counts are not atomic; making every incref/decref atomic would slow single-threaded code substantially. The GIL is released:

  • every sys.getswitchinterval() seconds (default 0.005 s) so other threads can run,
  • around blocking I/O (sockets, files, time.sleep, subprocess),
  • inside C extensions that opt out (NumPy’s heavy kernels, hashlib, compression).

6.2 Measured, on 2 cores

cores: 2 | python 3.11.15
sequential x4                        1.562s
threading x4 (CPU-bound)             1.641s     <- SLOWER than sequential (contention, no parallelism)
multiprocessing x4                   0.794s     <- ~2x, matching the core count
sequential sleep x4                  1.001s
threading sleep x4 (I/O)             0.251s     <- 4x: the GIL is released during sleep
switch interval: 0.005 s

That table is the answer to the GIL question. Four CPU-bound threads are 5% slower than doing the work one after another, because they take turns and pay for the handoffs. Four sleeping threads finish in a quarter of the time, because sleeping does not hold the lock.

6.3 Choosing a concurrency model

WorkloadUseWhy
Network/disk I/O, thousands of connectionsasyncioone thread, no context-switch cost, explicit yield points
I/O with blocking libraries you cannot changethreading / ThreadPoolExecutorthe GIL is released during the blocking call
CPU-bound pure Pythonmultiprocessing / ProcessPoolExecutorseparate interpreters, real cores
CPU-bound numericNumPy / numba / Cython / Rust extensionthe heavy loop leaves Python entirely and drops the GIL
Fan-out with a shared resultconcurrent.futuresone API for both pools, as_completed, map
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=16) as ex:              # I/O
    futures = {ex.submit(fetch, u): u for u in urls}
    for f in as_completed(futures):
        try:    handle(f.result())
        except Exception as e: log(futures[f], e)

with ProcessPoolExecutor() as ex:                            # CPU
    for out in ex.map(transform, chunks, chunksize=64):      # chunksize matters a lot
        collect(out)

Multiprocessing constraints worth stating: arguments and results must be picklable (no lambdas, no local classes, no open sockets); each worker pays interpreter startup plus the pickle round-trip, so tiny tasks lose to the overhead (chunksize exists for this); on Linux the default start method is fork (fast, but dangerous with threads and locks held) while macOS and Windows use spawn (safe, slower, re-imports your module — hence if __name__ == '__main__':); and shared state needs multiprocessing.shared_memory, Value/Array, or a Manager (which is a proxy over IPC and slow).

6.4 The 2026 picture

  • PEP 703 / PEP 779 — free-threaded CPython. 3.13 shipped an experimental --disable-gil build; 3.14 makes free-threaded builds officially supported (python3.14t), with the single-threaded penalty down to roughly 5–10%, and the specializing interpreter now working in that mode. It is still a separate build, not the default: the default 3.14 interpreter has a GIL. Extension modules must opt in (Py_GIL_DISABLED), and much of the ecosystem is still catching up.
  • PEP 744 — the JIT. A copy-and-patch tier-2 JIT, experimental; 3.14 ships it in the official Windows and macOS binaries but it is off by default and the gains are currently modest.
  • PEP 734 — concurrent.interpreters. Multiple interpreters in one process, each with its own GIL, with explicit channel-based communication. The CSP/actor answer: cheaper than processes, safer than threads.
  • Tail-call interpreter in 3.14 gives 3–5% on pyperformance when built with Clang 19+.

This interpreter is 3.11, so the free-threaded numbers above are cited, not measured here. Saying which of your claims are measured and which are cited is itself a good habit.


7. Memory management

7.1 Reference counting plus a cycle collector

Every object carries a refcount. When it hits zero the object is freed immediately and deterministically — which is why with blocks and CPython refcounting make explicit close() feel optional (it is not; PyPy and other implementations do not refcount).

getrefcount(n) = 2      # 1 real reference + 1 temporary for the argument itself

Reference counting cannot free cycles, so CPython adds a generational cyclic collector for container objects:

gc.get_threshold() -> (700, 10, 10)

Generation 0 is collected when allocations minus deallocations exceeds 700; generation 1 after 10 gen-0 collections; generation 2 after 10 gen-1 collections. Only objects that can participate in cycles are tracked (a list is tracked, an int is not).

import gc
class Node:
    def __init__(self): self.ref = None
a = Node(); b = Node(); a.ref = b; b.ref = a
del a, b                 # unreachable, but each refcount is still 1
gc.collect()             # this is what frees them

Interview-relevant knobs: gc.disable() for a short-lived batch process that allocates a lot and never cycles (a real speedup); gc.freeze() after startup so the long-lived module graph is never rescanned (the standard trick before forking web workers, since it also avoids copy-on-write page faults); gc.set_debug(gc.DEBUG_SAVEALL) plus gc.garbage to find what is cycling.

3.14 note: 3.14.0–3.14.4 shipped an incremental collector and it was reverted in 3.14.5 back to the 3.13 generational design after production memory-pressure reports.

7.2 Weak references

import weakref
class Big: pass
b = Big()
r = weakref.ref(b)
print(r() is b)          # True
del b
print(r())               # None

cache = weakref.WeakValueDictionary()    # entries vanish when the value is collected
registry = weakref.WeakSet()
weakref.finalize(obj, cleanup_fn, arg)   # the correct replacement for __del__

WeakValueDictionary is the answer to “how do you build a cache that does not leak”; note that objects with __slots__ need '__weakref__' in the slots to be weak-referenceable.

7.3 The allocator

CPython allocates in three tiers: blocks (fixed size classes up to 512 bytes) carved from pools (4 KB), carved from arenas (1 MB, mmaped). Requests over 512 bytes go to malloc. Consequences:

  • Freeing objects does not necessarily return memory to the OS — an arena is only released when it is entirely empty, so a fragmented heap keeps rss high. This is why long-running Python services often restart workers.
  • sys.getsizeof reports the object’s own size only, not what it references. For a real footprint you need a recursive walk (pympler.asizeof) or tracemalloc.
import tracemalloc
tracemalloc.start()
# ... suspect code ...
snap = tracemalloc.take_snapshot()
for stat in snap.statistics('lineno')[:10]:
    print(stat)
# and for a leak: take two snapshots and use snap2.compare_to(snap1, 'lineno')

7.4 List over-allocation, measured

capacity sequence: [4, 8, 16, 24, 32, 40, 52, 64, 76, 92, 108, 128, 148, 172, 200, 232, ...]
growth ratio: first=2.000  median=1.12569  asymptotic 1.125006
total reallocations while appending 1,000,000 items: 86

CPython’s list_resize is new = newsize + (newsize >> 3) + (3 or 6), rounded to a multiple of 4 — growth by one eighth. Compare CPython’s 9/8 with V8’s ~1.5x: Python trades more copying (9n total versus 2n) for much less wasted memory and a better chance that realloc can extend in place. Full derivation in Complexity 5.1.

Dicts are different: they use a compact layout (a dense array of entries plus a sparse array of indices), which is what made insertion order a side effect in 3.6 and a language guarantee in 3.7. They resize when 2/3 full, growing to 3x the used size. Instances of the same class share a key table (key-sharing dicts, PEP 412), which is why sys.getsizeof(obj.__dict__) for a second instance of the same class is much smaller than for the first.


8. How CPython executes your code

source ──> tokenizer ──> AST (ast module) ──> symbol table ──> bytecode (dis module)

                                                        ceval loop (Ignition-equivalent)

                                              PEP 659 specializing adaptive interpreter (3.11+)

                                                   PEP 744 copy-and-patch JIT (3.13+, experimental)

8.1 dis is the tool that settles arguments

List comprehension versus the equivalent loop, on 3.11:

### comprehension                          ### for-append loop
BUILD_LIST 0                               BUILD_LIST 0
LOAD_FAST .0                               STORE_FAST out
FOR_ITER                                   LOAD_FAST d / GET_ITER
  STORE_FAST x                             FOR_ITER
  LOAD_FAST x                                STORE_FAST x
  LOAD_CONST 2                               LOAD_FAST out
  BINARY_OP 5 (*)                            LOAD_METHOD append      <- extra work
  LIST_APPEND 2          <- one opcode        LOAD_FAST x
JUMP_BACKWARD                                 LOAD_CONST 2
                                              BINARY_OP 5 (*)
                                              PRECALL 1 / CALL 1     <- a real function call
                                              POP_TOP
                                            JUMP_BACKWARD

LIST_APPEND is a single opcode; the loop does LOAD_METHOD + PRECALL + CALL + POP_TOP per iteration. That is the mechanical reason comprehensions are faster, and it is a much better answer than “comprehensions are more Pythonic”. (Note that in 3.12+ comprehensions were inlined — no separate code object — making them faster still.)

8.2 LOAD_FAST vs LOAD_GLOBAL — the advice is now stale

Locals live in an array indexed at compile time (LOAD_FAST); globals require a dict lookup in the module namespace and then in builtins (LOAD_GLOBAL). The classic advice is “hoist globals into locals”. Measured on 3.11:

use_global        (1M iterations)   0.0343s
use_local         (1M iterations)   0.0345s        <- no difference
use_global_attr   (math.sqrt)       0.0631s
use_local_attr    (sqrt = math.sqrt) 0.0587s       <- 7% faster

PEP 659 specializes LOAD_GLOBAL into a version with an inline cache and a dict-version guard, so a plain global read now costs about the same as a local. What does still help is hoisting an attribute lookup (math.sqrt -> sqrt), because you skip a LOAD_ATTR, and hoisting a bound method out of a loop, which is still large when the loop body is small:

for x in r: out.append(x)          4.127s
ap = out.append; for x in r: ap(x) 0.808s      (5.1x)

Take that 5x with a grain of salt — the loop body here is only the call, so the saved LOAD_METHOD is the whole cost. In a loop that does real work the effect shrinks to noise. The general lesson is the right one though: verify the micro-optimization on your interpreter version, because the interpreter keeps getting smarter and the folklore does not get updated.

8.3 Other execution facts

  • python -O only sets __debug__ = False and strips assert statements and if __debug__ blocks. It does not optimize anything else. Never rely on assert for validation in production code.
  • Constant folding happens at compile time (2 ** 10 becomes 1024), and duplicate constants in one code object are deduplicated — which is what produced the 257 is 257 -> True confusion in section 1.
  • f.__code__.co_consts, co_varnames, co_freevars, co_cellvars let you inspect all of this.
  • 3.11 also added “zero-cost” exceptions: a try block that does not raise costs nothing at runtime (the handler table is consulted only when an exception is thrown), which changes the old advice that try blocks are expensive to enter. Raising is still expensive.

9. Performance idioms, measured

All numbers CPython 3.11.15, same container.

9.1 Loop shapes

[x*2 for x in data]                    28.3 us per 1000 elements
for-append loop                        29.1 us
list(map(lambda x: x*2, data))         53.3 us       <- the lambda costs a Python call per element

Comprehensions and hand loops are within 3% on 3.11; map with a lambda is nearly 2x slower because you pay a Python-level call. map with a C function (map(str, data), map(len, data)) wins instead — the rule is “map is fast only when the function is C”.

9.2 Dict access patterns

dict.get(k, default)                   31.3 ns
d[k] inside try/except  (hit)          25.7 ns      <- cheapest on the happy path (zero-cost try)
d[k] inside try/except  (miss)        159.6 ns      <- 6x more when it raises
'k' in d then d[k]                     40.8 ns      <- two lookups

The EAFP (try/except) pattern is now the fastest when misses are rare, and the worst when they are common. get is the balanced default. Doing if k in d: d[k] is always the loser — two hash lookups.

9.3 Exceptions as control flow

raise + catch                         144.6 ns
equivalent if-check                     6.9 ns      (21x)

Zero-cost try means entering a try block is free; raising still costs ~145 ns. So use exceptions for exceptional paths and conditionals for expected ones — for-else and sentinel returns exist for a reason. 145 ns is cheap in absolute terms, though: do not contort code to avoid one exception per request.

9.4 The checklist

DoInstead ofTypical win
set/dict membershipin listO(1) vs O(n) — 250x at n=4000
collections.dequelist.insert(0,..) / list.pop(0)O(1) vs O(n) — 145x at n=32000
''.join(parts)s += part when anything else holds a referencelinear vs quadratic
comprehension / genexprmap(lambda ...)~2x
hoist bound methods out of tight loopsattribute lookup per iterationup to 5x on trivial bodies
heapq.nlargest(k, xs)sorted(xs)[:k]O(n log k) vs O(n log n)
bisect on a pre-sorted listre-sortingavoids n log n per query
array/bytes/memoryview/NumPylist of numbers4–10x memory, avoids boxing
__slots__ or dataclass(slots=True)plain classes with many instances~31% memory
functools.cache on pure functionsrecomputationunbounded
operator.itemgetter(1) as a sort keylambda kv: kv[1]~30% on the sort
sys.intern for repeated runtime-built keysplain stringspointer-compare equality
gc.freeze() after startup, before forkingdefaultfewer COW faults

And the meta-rule: timeit for microbenchmarks, cProfile + pstats (or py-spy for a live process) to find where to look, tracemalloc for memory. Never optimize without a measurement — three of the “obvious” wins in this section turned out to be 0% on 3.11.


10. Exceptions

10.1 The hierarchy

BaseException
├── SystemExit, KeyboardInterrupt, GeneratorExit, asyncio.CancelledError
└── Exception
    ├── ArithmeticError (ZeroDivisionError, OverflowError)
    ├── LookupError (IndexError, KeyError)
    ├── OSError (FileNotFoundError, PermissionError, TimeoutError, ConnectionError...)
    ├── ValueError (UnicodeError), TypeError, AttributeError, NameError
    ├── RuntimeError (RecursionError, NotImplementedError)
    └── StopIteration, StopAsyncIteration, ImportError, MemoryError, ...

except Exception deliberately does not catch KeyboardInterrupt, SystemExit or CancelledError — which is why you should never write bare except:.

10.2 Semantics you must get right

try:
    r = risky()
except (ValueError, TypeError) as e:
    handle(e)                # `e` is deleted at the end of the block (it holds a traceback -> cycle)
except OSError:
    raise                    # re-raise, preserving the original traceback
except Exception as e:
    raise ProcessingError('context') from e     # explicit chaining -> __cause__
else:
    use(r)                   # runs only if NO exception — put the happy path here, not in try
finally:
    cleanup()                # always runs

raise X from e sets __cause__ (“this was caused by”). An exception raised during handling sets __context__ automatically (“during handling of the above, another occurred”). raise X from None suppresses the chain — useful when the inner error is an implementation detail.

The finally traps:

def f():
    try: return 'a'
    finally: return 'b'      # -> 'b'. The finally's return DISCARDS the exception or return value.

def g():
    for i in range(3):
        try: raise ValueError
        finally: break       # swallows the exception; 3.14 emits a SyntaxWarning for this

10.3 Exception groups (3.11+, runnable here)

try:
    raise ExceptionGroup('batch failed', [ValueError('a'), TypeError('b'), ValueError('c')])
except* ValueError as eg:
    print('values:', [str(e) for e in eg.exceptions])     # values: ['a', 'c']
except* TypeError as eg:
    print('types :', [str(e) for e in eg.exceptions])     # types : ['b']

except* runs every matching clause (unlike except, which runs the first) and re-raises whatever is unhandled as a smaller group. This is what asyncio.TaskGroup raises when several children fail, and it is the right model for any fan-out.


11. Typing in modern Python

11.1 The essentials

from typing import (Optional, Literal, Final, TypedDict, NamedTuple, Protocol, runtime_checkable,
                    TypeVar, Generic, ParamSpec, Concatenate, Self, overload, cast, assert_never,
                    NewType, ClassVar, Annotated)
from collections.abc import Iterable, Callable, Sequence, Mapping, Iterator
from dataclasses import dataclass, field

Number = int | float                  # 3.10+ union syntax; Optional[X] == X | None
Mode = Literal['r', 'w', 'a']
MAX: Final = 100
UserId = NewType('UserId', int)       # nominal-ish: a distinct type at check time, an int at runtime

class Config(TypedDict, total=False):
    host: str
    port: int

class Point(NamedTuple):
    x: float
    y: float = 0.0

@dataclass(frozen=True, slots=True, order=True)
class Vec:
    x: float
    y: float
    tags: list[str] = field(default_factory=list, compare=False)

Always annotate parameters with the abstract type (Iterable, Mapping, Sequence) and returns with the concrete one (list, dict) — accept the widest thing you can use, promise the most specific thing you produce.

11.2 Structural typing with Protocol

@runtime_checkable
class Comparable(Protocol):
    def __lt__(self, other: Self, /) -> bool: ...

class SupportsClose(Protocol):
    def close(self) -> None: ...

def close_all(items: Iterable[SupportsClose]) -> None:
    for i in items: i.close()

Protocol is Python’s structural typing — the duck-typing equivalent of a TypeScript interface. No implements needed, nothing to inherit. @runtime_checkable enables isinstance but only checks method presence, not signatures.

ProtocolABC (abc.ABC)
Relationshipstructural (shape)nominal (must inherit or register)
Third-party classesconform automaticallyneed explicit registration
Enforced at runtimeno (unless runtime_checkable, shallowly)yes, at instantiation
Can provide implementationsdefault methods, yesyes (that is the point of a mixin)

11.3 Generics

T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')

class Stack(Generic[T]):
    def __init__(self) -> None: self._items: list[T] = []
    def push(self, item: T) -> None: self._items.append(item)
    def pop(self) -> T: return self._items.pop()

def with_retry(fn: Callable[P, R]) -> Callable[P, R]: ...              # signature preserved
def add_conn(fn: Callable[Concatenate[Conn, P], R]) -> Callable[P, R]: ...  # first arg injected

# variance
T_co = TypeVar('T_co', covariant=True)      # for read-only containers (producers)
T_contra = TypeVar('T_contra', contravariant=True)   # for consumers (callbacks)

class Builder:
    def add(self, x: int) -> Self: return self          # 3.11+: fluent chaining in subclasses

PEP 695 (3.12+) replaces most of that boilerplate:

# 3.12+ only
type Alias[T] = list[dict[str, T]]
def first[T](xs: Sequence[T]) -> T | None: ...
class Stack[T]:
    def push(self, item: T) -> None: ...

PEP 649/749 (3.14) makes annotations lazily evaluated by default, which removes most of the need for from __future__ import annotations and string forward references, and adds the annotationlib module with Format.VALUE/FORWARDREF/STRING so tools can ask for whichever they can handle.

11.4 Checkers

mypypyright / pylance
Default strictnesspermissive; needs --strictstricter out of the box
Speedslowermuch faster (TS-based, incremental)
Narrowinggoodbetter (more CFA cases)
Plugin ecosystemyes (django, sqlalchemy, attrs)limited
Bundled with an editornoyes (VS Code)

Useful escape hatches in both: # type: ignore[error-code], cast(T, v), typing.TYPE_CHECKING for import-cycle-only imports, reveal_type(x) while debugging, and assert_never(x) for exhaustiveness:

def area(s: Circle | Square) -> float:
    match s:
        case Circle(r=r): return 3.14159 * r * r
        case Square(a=a): return a * a
        case _: assert_never(s)       # a checker error if a member is unhandled

12. Standard-library power tools

12.1 collections

from collections import deque, defaultdict, Counter, OrderedDict, ChainMap, namedtuple

dq = deque([1,2,3], maxlen=3)      # maxlen makes it a sliding window: appending drops from the front
dq.append(4)                        # deque([2,3,4])
dq.rotate(1)                        # O(k)
# O(1) at both ends; O(n) for dq[i] in the middle — NOT random access

graph = defaultdict(list)           # graph[u].append(v) with no key check
counts = defaultdict(int)
nested = defaultdict(lambda: defaultdict(int))
# NOTE: reading a missing key CREATES it. Use .get() when you only want to look.

c = Counter('mississippi')
c.most_common(3)                    # [('i',4),('s',4),('p',2)]  — O(n log k)
c1 - c2; c1 + c2; c1 & c2; c1 | c2  # multiset arithmetic; subtraction drops non-positives

od = OrderedDict()                  # still useful: move_to_end(), order-sensitive __eq__, popitem(last=)
cm = ChainMap(overrides, defaults)  # layered lookup without merging

deque is the single most useful import for interview problems: BFS queues, sliding-window maxima, monotonic queues, and maxlen for “last k items”.

12.2 heapq and bisect

import heapq, bisect

h = []
heapq.heappush(h, (priority, tiebreak_counter, item))   # tuples compare lexicographically;
                                                        # add a counter so `item` is never compared
heapq.heapify(existing_list)                            # O(n), in place
heapq.heappop(h)
heapq.heappushpop(h, x)     # push then pop, one sift — cheaper than two calls
heapq.heapreplace(h, x)     # pop then push
heapq.nlargest(k, it, key=...)   # O(n log k)
heapq.merge(*sorted_iterables)   # lazy k-way merge, O(1) memory per stream

# max-heap: negate the key (3.14 adds heappush_max / heappop_max / heapify_max)
heapq.heappush(h, -value)

i = bisect.bisect_left(sorted_xs, target)    # first index where xs[i] >= target
j = bisect.bisect_right(sorted_xs, target)   # first index where xs[i] >  target
count_equal = j - i
bisect.insort(sorted_xs, x)                  # CAREFUL: O(n) memmove, so building is O(n^2)
bisect.bisect_left(rows, target, key=lambda r: r.score)   # key= is 3.10+

12.3 itertools

from itertools import (chain, islice, accumulate, pairwise, groupby, product, permutations,
                       combinations, combinations_with_replacement, cycle, repeat, count,
                       zip_longest, tee, takewhile, dropwhile, starmap, compress, filterfalse)

list(accumulate([1,2,3,4]))                      # [1,3,6,10]  — prefix sums
list(accumulate([3,1,4], max))                   # [3,3,4]     — running max
list(pairwise('abcd'))                           # [('a','b'),('b','c'),('c','d')]  (3.10+)
list(islice(count(10, 2), 4))                    # [10,12,14,16]
[list(g) for k, g in groupby('aaabbc')]          # [['a','a','a'],['b','b'],['c']]
#   groupby only groups CONSECUTIVE equal keys — sort first or you get surprises
list(product('ab', repeat=2))                    # [('a','a'),('a','b'),('b','a'),('b','b')]
list(combinations(range(4), 2))                  # C(4,2) = 6 tuples
list(chain.from_iterable([[1,2],[3]]))           # [1,2,3]  — flatten one level
a, b = tee(iterable)                             # two independent iterators (buffers internally)

Recipes worth memorizing: sliding_window via deque(maxlen=k), unique_everseen via a set, flatten via chain.from_iterable, chunked via iter(lambda: list(islice(it, n)), []), and take(n, it) = list(islice(it, n)).

12.4 Others

import re, math, statistics, struct, array, json, enum, pathlib, dataclasses

re.compile(pattern)              # compile once outside loops; the module-level cache is only 512 entries
# catastrophic backtracking: (a+)+$ on 'aaaa...b' is exponential. Prefer non-backtracking constructs,
# atomic groups (3.11+ (?>...)), possessive quantifiers (3.11+ a++), or a real parser.

math.isqrt, math.comb, math.perm, math.gcd, math.lcm, math.prod, math.dist, math.hypot
statistics.median, mean, mode, quantiles, fmean

class Color(enum.Enum):          # StrEnum/IntEnum for interop; auto() for values
    RED = 'red'
class Flags(enum.Flag):
    A = enum.auto(); B = enum.auto()      # bitwise combinable

13. Interview questions

Q: What does the GIL prevent, and what does it not?

A: It prevents two threads executing Python bytecode simultaneously, so CPU-bound threads do not scale (measured: 4 threads were 5% slower than sequential). It does not prevent concurrency during I/O or inside GIL-releasing C code (measured: 4 sleeping threads were 4x faster). It also does not make your code thread-safe — x += 1 is still three bytecodes and can interleave.

Q: is vs ==?

A: Identity vs value. Use is only for None and other singletons. The 256/257 behaviour is two separate mechanisms: the small-int cache [-5, 256], and per-code-object constant deduplication.

Q: Deep vs shallow copy?

A: Shallow copies the container and shares the elements; deep recursively copies, using a memo dict so cycles terminate and shared references stay shared. list(x), x[:], dict(d) are all shallow.

Q: Why is a mutable default argument a bug?

A: The default object is created once at function definition and reused for every call that omits the argument, so mutations persist across calls. Use None and create inside.

Q: Generators vs iterators?

A: An iterator implements __next__. A generator is a function that produces an iterator, with the state machine written by the compiler. Generators additionally support send/throw/close.

Q: What does yield from do beyond looping?

A: It forwards send, throw and close to the sub-generator and evaluates to its return value. A hand-written for x in inner(): yield x does neither.

Q: Decorator with arguments — how many levels?

A: Three: the factory takes the arguments, returns the decorator, which takes the function and returns the wrapper. Always @functools.wraps the wrapper.

Q: In what order do stacked decorators apply?

A: Bottom-up. @a @b def f is f = a(b(f)), so a’s wrapper is outermost and runs first.

Q: __new__ vs __init__?

A: __new__ allocates and returns the instance (a static method on cls); __init__ initializes the instance __new__ returned. Override __new__ for immutable subclasses, singletons, and factory dispatch. If __new__ returns a non-instance, __init__ is skipped.

Q: What is super() really?

A: A proxy for the next class in type(self).__mro__ after the current class — not the parent. That is why in D(B, C), B.who’s super() resolves to C.

Q: How does C3 linearization work?

A: It merges the bases’ MROs preserving local precedence order and monotonicity: a class always precedes its bases, and the relative order of bases is preserved. If no consistent order exists, class creation raises TypeError.

Q: What is a metaclass and when have you needed one?

A: The type of a class; type is the default. __new__/__init__ on a metaclass run at class creation, which is how ABCs, ORM models, and enum member collection work. Honest answer for the follow-up: almost never in application code — __init_subclass__ (3.6+) or a class decorator covers 95% of cases with far less magic.

Q: Explain descriptors, and name three you use daily.

A: Objects with __get__/__set__/__delete__ stored on a class, invoked on attribute access. property (data), plain functions (non-data — their __get__ produces bound methods), classmethod/staticmethod, __slots__ members, and cached_property.

Q: Why do data descriptors beat the instance dict?

A: Because __getattribute__ checks the type’s MRO for a data descriptor before consulting obj.__dict__. That ordering is what lets a property intercept an assignment that would otherwise just write an instance attribute.

Q: What makes an object hashable?

A: A __hash__ that is consistent with __eq__ and stable for the object’s lifetime in a container. Defining __eq__ sets __hash__ = None unless you define it too.

Q: Why does dict preserve insertion order?

A: The compact layout (3.6): a dense array of (hash, key, value) entries in insertion order plus a sparse index array. It was an implementation detail in 3.6 and a language guarantee from 3.7.

Q: list vs tuple — beyond mutability?

A: Tuples are hashable (so usable as dict keys and set members), have no over-allocation (64 bytes for 3 elements vs 88), are cached/reused by CPython, and signal “record” rather than “collection”.

Q: __slots__ — what do you gain and lose?

A: Gain ~31% memory (measured) and no ad-hoc attributes. Lose __dict__, weak-referenceability (unless declared), and some multiple-inheritance flexibility. On 3.11 there is no measurable speed gain — the old advice is stale.

Q: How do you avoid a RecursionError?

A: Convert to iteration with an explicit stack, memoize to shrink the tree, or (last resort) sys.setrecursionlimit. The default is ~1000 frames, so any recursion over 1e5 items must be iterative.

Q: What is monkeypatching and when is it acceptable?

A: Replacing an attribute on a module or class at runtime. Acceptable in tests (unittest.mock.patch is exactly this, scoped and reverted) and for hot-fixing a third-party bug. Not in application code, because it makes behaviour depend on import order.

Q: lru_cache pitfalls?

A: Unhashable arguments fail; f(1) and f(x=1) are distinct keys; it keeps strong references so decorating a method leaks every self; it is per-process; and an unbounded cache on user input is a memory-exhaustion vector.

Q: How do you profile a slow Python program?

A: cProfile + pstats (or py-spy/Austin for a running process) to find the hot function, then timeit on that function’s alternatives, then tracemalloc if memory is the issue. Look at cumulative time first, then per-call.

Q: Explain EAFP vs LBYL and which is faster.

A: “Easier to ask forgiveness than permission” (try/except) versus “look before you leap” (if k in d). Measured on 3.11: try/except is fastest on the happy path (25.7 ns vs 40.8 ns) thanks to zero-cost exceptions, and 6x worse when it actually raises. EAFP also avoids TOCTOU races.

Q: Is x += 1 atomic?

A: No. It is LOAD, BINARY_OP, STORE and the GIL can be released between them. Use a Lock, or an atomic-by-design structure like queue.Queue or itertools.count().

Q: What is the difference between Protocol and an ABC?

A: Structural vs nominal. A Protocol matches any class with the right shape, including classes you do not control; an ABC requires inheritance or explicit register, and enforces at instantiation.

Q: What is asyncio.gather vs TaskGroup?

A: gather returns results in argument order and, by default, propagates the first exception while leaving siblings running. TaskGroup (3.11+) is structured: it cancels siblings on failure and raises an ExceptionGroup. Prefer TaskGroup for new code.

Q: What does await do?

A: Suspends the coroutine and yields control to the event loop until the awaitable completes. It is not a thread hand-off — everything runs on one thread, so a blocking call inside a coroutine stalls every other task.

Q: Predict the output.

def f(n, acc=[]):
    acc.append(n); return acc
print(f(1), f(2), f(3))

A: [1, 2, 3] [1, 2, 3] [1, 2, 3] — one shared list, and print shows the same object three times after all three calls have run.

Q: Predict the output.

print([lambda: i for i in range(3)][0]())
x = [1, 2, 3]
y = x
x = x + [4]     # rebinding
print(y)
x = [1, 2, 3]; y = x
x += [4]        # in-place (list.__iadd__ == extend)
print(y)

A: 2, then [1, 2, 3], then [1, 2, 3, 4]. Late binding for the first; + creates a new list while += mutates in place, which is the standard “why did my caller’s list change” bug.

Q: Predict the output.

class A:
    x = []
a, b = A(), A()
a.x.append(1)     # mutates the CLASS attribute
a.y = 2           # creates an INSTANCE attribute
print(b.x, hasattr(b, 'y'))

A: [1] False. Mutating a mutable class attribute affects every instance; assignment creates a per-instance shadow.


Next: Data structures in Python, or the mirror-image runtime chapter, JavaScript and Node core.

Verify it yourself

pyv/gil.py

import time, threading, multiprocessing, sys, os
def cpu(n=6_000_000):
    x = 0
    for i in range(n): x += i*i
    return x
def timeit(label, fn):
    t = time.perf_counter(); fn(); print(f"{label:<34}{time.perf_counter()-t:7.3f}s")
N = 4
print("cores:", os.cpu_count(), "| python", sys.version.split()[0])
timeit(f"sequential x{N}", lambda: [cpu() for _ in range(N)])
def threads():
    ts = [threading.Thread(target=cpu) for _ in range(N)]
    [t.start() for t in ts]; [t.join() for t in ts]
timeit(f"threading x{N} (CPU-bound)", threads)
def procs():
    with multiprocessing.Pool(N) as p: p.map(cpu_wrapper, range(N))
def cpu_wrapper(_): return cpu()
timeit(f"multiprocessing x{N}", procs)
# I/O bound
def io(): time.sleep(0.25)
timeit(f"sequential sleep x{N}", lambda: [io() for _ in range(N)])
def io_threads():
    ts = [threading.Thread(target=io) for _ in range(N)]
    [t.start() for t in ts]; [t.join() for t in ts]
timeit(f"threading sleep x{N} (I/O)", io_threads)
print("switch interval:", sys.getswitchinterval(), "s")

pyv/perf.py

import timeit, sys
N = 2_000_000
def bench(label, stmt, setup="pass", number=N):
    t = timeit.timeit(stmt, setup=setup, number=number)
    print(f"{label:<44}{t:7.4f}s  {t/number*1e9:8.1f} ns/op")
bench("LOAD_GLOBAL (module-level name)", "x", "x=1", 5_000_000)
bench("LOAD_FAST (inside function)", "f()", "x=1\ndef f():\n y=1\n return y", 5_000_000)
print()
setup="data=list(range(1000))"
bench("[x*2 for x in data]", "[x*2 for x in data]", setup, 20_000)
bench("list(map(lambda x: x*2, data))", "list(map(lambda x:x*2,data))", setup, 20_000)
bench("for-append loop", "out=[]\nfor x in data: out.append(x*2)", setup, 20_000)
print()
bench("dict.get(k, d)", "d.get('k',0)", "d={'k':1}", 5_000_000)
bench("d['k'] in try/except (hit)", "\ntry:\n d['k']\nexcept KeyError:\n pass", "d={'k':1}", 5_000_000)
bench("d['miss'] in try/except (miss)", "\ntry:\n d['miss']\nexcept KeyError:\n pass", "d={'k':1}", 2_000_000)
bench("'k' in d then d['k']", "\nif 'k' in d: d['k']", "d={'k':1}", 5_000_000)
print()
bench("exception raise+catch", "\ntry:\n raise ValueError()\nexcept ValueError:\n pass", number=1_000_000)
bench("if-check instead", "\nif False:\n pass", number=1_000_000)
print()
bench("local alias of method in loop", "ap=out.append\nfor x in r: ap(x)", "out=[];r=range(100)", 100_000)
bench("attribute lookup in loop", "for x in r: out.append(x)", "out=[];r=range(100)", 100_000)

pyv/perf2.py

import timeit, dis, sys
def bench(label, stmt, setup="pass", number=1_000_000):
    t = timeit.timeit(stmt, setup=setup, number=number)
    print(f"{label:<46}{t:7.4f}s  {t/number*1e9:8.1f} ns/op")

# locals vs globals, measured INSIDE a function so LOAD_FAST vs LOAD_GLOBAL is the only difference
src = """
G = 1
def use_global(n):
    s = 0
    for _ in range(n): s += G
    return s
def use_local(n):
    L = 1
    s = 0
    for _ in range(n): s += L
    return s
import math
def use_global_attr(n):
    s = 0.0
    for i in range(n): s += math.sqrt(i)
    return s
def use_local_attr(n):
    sqrt = math.sqrt
    s = 0.0
    for i in range(n): s += sqrt(i)
    return s
"""
ns = {}
exec(src, ns)
for name in ("use_global", "use_local", "use_global_attr", "use_local_attr"):
    t = timeit.timeit(f"{name}(1_000_000)", globals=ns, number=5)
    print(f"{name:<46}{t/5:7.4f}s per 1M iters")

print()
print("dis of a global read vs a local read (3.11 bytecode):")
dis.dis(ns["use_global"].__code__.co_consts and ns["use_global"])

pyv/perf3.py

import timeit
setup = "out=[]"
t1 = timeit.timeit("for x in r: out.append(x)", "out=[];r=range(1000)", number=20_000)
t2 = timeit.timeit("ap=out.append\nfor x in r: ap(x)", "out=[];r=range(1000)", number=20_000)
print(f"attribute lookup per call : {t1:.3f}s")
print(f"hoisted local alias       : {t2:.3f}s   ({t1/t2:.2f}x faster)")

pyv/slots.py

import sys, timeit
try:
    from pympler.asizeof import asizeof
except ImportError:
    asizeof = None
class Plain:
    def __init__(self, a, b): self.a, self.b = a, b
class Slotted:
    __slots__ = ('a','b')
    def __init__(self, a, b): self.a, self.b = a, b
p, s = Plain(1,2), Slotted(1,2)
print("Plain   sys.getsizeof(obj) =", sys.getsizeof(p), "+ __dict__", sys.getsizeof(p.__dict__))
print("Slotted sys.getsizeof(obj) =", sys.getsizeof(s), "(no __dict__)")
import tracemalloc
for cls, name in ((Plain,'Plain'), (Slotted,'Slotted')):
    tracemalloc.start()
    objs = [cls(i, i) for i in range(200_000)]
    cur, peak = tracemalloc.get_traced_memory(); tracemalloc.stop()
    print(f"200,000 x {name:<8} = {cur/1e6:6.2f} MB  ({cur/200_000:.0f} bytes/obj)")
    del objs
t1 = timeit.timeit("o.a", setup="from __main__ import Plain; o=Plain(1,2)", number=5_000_000)
t2 = timeit.timeit("o.a", setup="from __main__ import Slotted; o=Slotted(1,2)", number=5_000_000)
print(f"attribute read: dict {t1:.3f}s  slots {t2:.3f}s  ({t1/t2:.2f}x)")