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
- 1. The object model
- 2. The data model (dunder protocols)
- 3. Attribute lookup, descriptors, and the MRO
- 4. Iterators, generators, coroutines
- 5. Functions, closures, decorators
- 6. Concurrency and the GIL
- 7. Memory management
- 8. How CPython executes your code
- 9. Performance idioms, measured
- 10. Exceptions
- 11. Typing in modern Python
- 12. Standard-library power tools
- 13. Interview questions
Gap note. Traps this chapter does not cover —
for/else, name mangling, the exception variable unbinding,ExceptionvsBaseException, generatorfinallytiming,Decimal, and thematchbare-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
| Immutable | Mutable |
|---|---|
int, float, complex, bool, str, bytes, tuple, frozenset, range | list, 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:
- If
a == bthenhash(a) == hash(b). - A hash must not change while the object is in a hash-based container.
- 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
| Dunder | Enables |
|---|---|
__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:
| Thing | Kind | Why |
|---|---|---|
| a plain function | non-data descriptor | its __get__ returns a bound method |
property | data descriptor | has __set__, so it beats the instance dict |
classmethod / staticmethod | non-data | __get__ returns a bound-to-class / plain function |
__slots__ members | data | array-slot getters and setters |
functools.cached_property | non-data | computes 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; theyieldexpression evaluates tov. You mustnext()(prime) first.gen.throw(exc)— raise at the suspension point, catchable inside.gen.close()— raiseGeneratorExitthere;finallyblocks run.yield from it— delegate, forwardingsend/throwand 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
| Combinator | Behaviour on error | Order of results |
|---|---|---|
gather(*aws) | first exception propagates, siblings keep running | argument order |
gather(*aws, return_exceptions=True) | exceptions become results | argument order |
as_completed(aws) | you see each as it settles | completion order |
TaskGroup (3.11+) | cancels all siblings, raises ExceptionGroup | you 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
| Workload | Use | Why |
|---|---|---|
| Network/disk I/O, thousands of connections | asyncio | one thread, no context-switch cost, explicit yield points |
| I/O with blocking libraries you cannot change | threading / ThreadPoolExecutor | the GIL is released during the blocking call |
| CPU-bound pure Python | multiprocessing / ProcessPoolExecutor | separate interpreters, real cores |
| CPU-bound numeric | NumPy / numba / Cython / Rust extension | the heavy loop leaves Python entirely and drops the GIL |
| Fan-out with a shared result | concurrent.futures | one 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-gilbuild; 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
pyperformancewhen 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
rsshigh. This is why long-running Python services often restart workers. sys.getsizeofreports the object’s own size only, not what it references. For a real footprint you need a recursive walk (pympler.asizeof) ortracemalloc.
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 -Oonly sets__debug__ = Falseand stripsassertstatements andif __debug__blocks. It does not optimize anything else. Never rely onassertfor validation in production code.- Constant folding happens at compile time (
2 ** 10becomes1024), and duplicate constants in one code object are deduplicated — which is what produced the257 is 257 -> Trueconfusion in section 1. f.__code__.co_consts,co_varnames,co_freevars,co_cellvarslet you inspect all of this.- 3.11 also added “zero-cost” exceptions: a
tryblock that does not raise costs nothing at runtime (the handler table is consulted only when an exception is thrown), which changes the old advice thattryblocks 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
| Do | Instead of | Typical win |
|---|---|---|
set/dict membership | in list | O(1) vs O(n) — 250x at n=4000 |
collections.deque | list.insert(0,..) / list.pop(0) | O(1) vs O(n) — 145x at n=32000 |
''.join(parts) | s += part when anything else holds a reference | linear vs quadratic |
| comprehension / genexpr | map(lambda ...) | ~2x |
| hoist bound methods out of tight loops | attribute lookup per iteration | up 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 list | re-sorting | avoids n log n per query |
array/bytes/memoryview/NumPy | list of numbers | 4–10x memory, avoids boxing |
__slots__ or dataclass(slots=True) | plain classes with many instances | ~31% memory |
functools.cache on pure functions | recomputation | unbounded |
operator.itemgetter(1) as a sort key | lambda kv: kv[1] | ~30% on the sort |
sys.intern for repeated runtime-built keys | plain strings | pointer-compare equality |
gc.freeze() after startup, before forking | default | fewer 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.
Protocol | ABC (abc.ABC) | |
|---|---|---|
| Relationship | structural (shape) | nominal (must inherit or register) |
| Third-party classes | conform automatically | need explicit registration |
| Enforced at runtime | no (unless runtime_checkable, shallowly) | yes, at instantiation |
| Can provide implementations | default methods, yes | yes (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
| mypy | pyright / pylance | |
|---|---|---|
| Default strictness | permissive; needs --strict | stricter out of the box |
| Speed | slower | much faster (TS-based, incremental) |
| Narrowing | good | better (more CFA cases) |
| Plugin ecosystem | yes (django, sqlalchemy, attrs) | limited |
| Bundled with an editor | no | yes (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)")