Python internalsPythonCPython 3.12

objects · bytecode · memory · data model · generators · GIL

See what CPython really does.

Twelve chapters on how CPython works underneath, written for interviews where "it just works" is not an answer. Each chapter explains one layer of the interpreter, proves every claim with a program and its real output, and then gives you about twenty drills: concept checks, tricky questions, predict-the-output and find-the-bug snippets, and coding problems with reference solutions.

a = [1, 2]
b = a
a += [3]
a = a + [4]
print(a, b)
[1, 2, 3, 4] [1, 2, 3]

+= on a list calls __iadd__, which extends the list in place, so b sees the 3. a + [4] builds a new list and rebinds only a. The output is [1, 2, 3, 4] [1, 2, 3]. Chapter 1 explains why.

Chapters

Each chapter has three kinds of section: Theory, Drills and Coding. Use the switch in the top bar to show only one kind. Every program on these pages was run with CPython 3.12, and the output under it is what it printed.

How to use the drills

Answer each drill out loud or on paper before you open it, and treat a wrong answer as the thing to revise. Predict-the-output snippets were run with CPython 3.12. Where a result depends on the version or on CPython rather than the language, the answer says so. Press / to search, and use the contents list on the right to move around a chapter.

Suggested plan

WeekReadDrill
1Ch 1–3 (objects, names, bytecode)All drills in Ch 1–3, then use dis on your own functions
2Ch 4–6 (memory, containers, strings)Memory and container drills, then write the LRU cache and the interning pool
3Ch 7–9 (data model, attributes, classes)Descriptor and metaclass drills, then write a validated-field descriptor
4Ch 10–12 (generators, GIL, imports)Generator, asyncio and threading drills, then the rate limiter and the mini event loop
5Ch 13Two timed mock rounds, then redo every drill you missed

What "CPython" means here

Python is a language specification, and CPython is the reference implementation written in C. Most interview questions about "how Python works" are really about CPython: reference counting, the GIL, id() returning an address and the small-int cache are all CPython details. PyPy, for example, uses a tracing JIT and a moving garbage collector instead. The guide marks these points as CPython behaviour so you can say so in an interview.

Part A · The object model · Chapter 1

Everything is an object

In CPython every value, including every int, function, class and module, is a C struct on the heap that starts with a reference count and a pointer to its type. This chapter covers what that header holds, what is, == and id() actually compare, which objects CPython caches and shares, and why assignment never copies anything. Interviewers use these questions to check that you can predict aliasing bugs before you write them.

TheoryDrillsCoding· about 3 hours· prerequisites: basic Python, the idea of a pointer

What a Python object is

Theory

Every object CPython creates is a C struct allocated on the heap. Whatever else it holds, it begins with the same two fields, defined as PyObject in Include/object.h:

  • ob_refcnt: how many references point at the object. When it drops to zero the object is freed immediately (Chapter 4).
  • ob_type: a pointer to the object's type object, which is itself a Python object. The type decides what the object can do: how to add it, hash it, print it, get an attribute from it.

Objects whose size varies, such as int, tuple, bytes and list, use PyVarObject, which adds an ob_size field (the number of items or digits). After the header comes the type-specific payload: the digits of an int, the item pointers of a tuple, the bytes of a string.

A variable is not a box that holds a value. A name is an entry in a namespace (a dict, or a slot in a frame) that holds a pointer to an object. x = 5 makes the name x point at an int object. The object does not know which names point at it, only how many references exist.

x y PyLongObject (an int) ob_refcnt 2 ob_type ─────┐ digits: 5 PyTypeObject "int" tp_name "int" tp_hash long_hash tp_as_number … ob_type → type
Names hold pointers. After x = 5; y = x there is one int object with two references. Its ob_type points at the int type object, whose own type is type.

Because types are objects too, you can ask any type for its type. The chain stops at type, which is its own type, and every class including type inherits from object:

print(type(5), type(int), type(type))
print(int.__mro__)
print(isinstance(type, object), isinstance(object, type))
print(issubclass(type, object), issubclass(object, type))
<class 'int'> <class 'type'> <class 'type'>
(<class 'int'>, <class 'object'>)
True True
True False

How big an object is

sys.getsizeof returns the size of the object's own struct in bytes, not counting objects it points to. On a 64-bit build the two header fields already take 16 bytes, so even an empty object is not small:

import sys
for label, v in [("object()", object()), ("None", None), ("1.0", 1.0), ("0", 0), ("1", 1),
                 ("2**30", 2**30), ("2**60", 2**60), ('""', ""), ('"abc"', "abc"),
                 ("()", ()), ("[]", []), ("{}", {}), ("set()", set())]:
    print(f"{label:>9} {sys.getsizeof(v):4}")
 object()   16
     None   16
      1.0   24
        0   28
        1   28
    2**30   32
    2**60   36
       ""   41
    "abc"   44
       ()   40
       []   56
       {}   64
    set()  216

An int stores its magnitude in 30-bit "digits". 0 and 1 both need one digit, so they are 28 bytes, and every 30 bits of magnitude adds 4 bytes. A list's 56 bytes hold only the header and a pointer to a separate array of item pointers, which is why getsizeof of a list never includes its items.

Interview trap

sys.getsizeof([1, 2, 3]) does not measure the ints. To estimate the real footprint of a container you have to walk it and add up the size of each distinct object, which is what tools such as tracemalloc and pympler do.

Immortal objects (3.12)

Before 3.12, None, True, small ints and interned strings had ordinary reference counts that every use incremented and decremented. That meant constant writes to shared memory, which hurts multi-core performance. PEP 683 made these objects immortal: their refcount is set to a huge sentinel value and Py_INCREF and Py_DECREF leave it alone.

import sys
print(sys.getrefcount(None))
print(sys.getrefcount(7))
x = object()
print(sys.getrefcount(x))
4294967295
4294967295
2

getrefcount reports one more than you might expect for x because passing x as an argument creates a temporary reference.

Identity, equality and is

Theory

Python has two different questions about two objects:

  • a is b asks whether they are the same object. In CPython this compares two pointers and cannot be overridden.
  • a == b asks whether they are equal. It calls a.__eq__(b) (and may fall back to b.__eq__(a)), so a class decides what equality means.

id(obj) returns an integer that is unique among objects that exist at the same time. In CPython it is the object's memory address. Two objects with non-overlapping lifetimes may get the same id, because the memory of the first was reused:

print(id([]) == id([]))
a = []
b = []
print(id(a) == id(b))
True
False

In the first line, the first list is freed as soon as id() returns, and the second list is allocated at the same address. In the second pair both lists are alive at once, so their ids differ.

When equality is not reflexive

float("nan") is not equal to itself, as IEEE 754 requires. Container comparisons, however, check identity before equality as an optimisation (PyObject_RichCompare with Py_EQ inside list comparison uses is first). So the same NaN object is "found" in a list even though it is not equal to itself:

x = float("nan")
print(x == x, x is x)
print([x] == [x], x in [x])
print(float("nan") in [float("nan")])
False True
True True
False

Key idea

Use is only for singletons such as None, True, False, Ellipsis and sentinel objects you created yourself. Use == for everything else. Code that uses is on numbers or strings may pass on your machine and fail on another Python version.

Caches, constants and interning

Theory

Some objects are shared because CPython creates them once and hands out the same object every time. This is an implementation detail, but interviewers love it because it makes is look inconsistent.

The small-int cache

CPython pre-creates the ints from −5 to 256 when the interpreter starts. Any operation that produces one of these values returns the cached object. Values outside the range get a fresh object each time they are computed at run time:

a = int("256"); b = int("256")
c = int("257"); d = int("257")
print(a is b, c is d)
e = 250 + 6; f = 251 + 6
print(e is int("256"), f)
True False
True 257

Constants in the same code object

The compiler collects the literals of a function or module into a tuple of constants, co_consts, and merges equal ones. Two literals 257 written in the same compilation unit therefore end up as the same object, even though 257 is outside the small-int cache. Compiled separately, they are different objects:

a = 257
b = 257
print(a is b)

ns = {}
exec("x = 257", ns)
exec("y = 257", ns)
print(ns["x"] is ns["y"])

def f():
    return 1000, 1000
p, q = f()
print(p is q, f.__code__.co_consts)
True
False
True (None, (1000, 1000))

This is why a is b gives a different answer in a script, in the interactive prompt (where each line is compiled separately) and inside a function. None of these answers is a language guarantee.

String interning

Strings that look like identifiers (letters, digits and underscores) and that appear as constants in code are interned: CPython keeps one copy in a global table so that attribute and variable lookups can compare pointers before comparing characters. Strings built at run time are not interned unless you call sys.intern:

import sys
a = "hello_world"
b = "hello_" + "world"          # folded at compile time, then interned
c = "".join(["hello_", "world"])  # built at run time
print(a is b, a is c, a == c)
print(sys.intern(c) is a)
s = "hello world!"
t = "hello world!"
print(s is t)
True False True
True
True

s is t is True here only because both literals live in the same code object, not because the string was interned (it contains a space and !).

Tip

sys.intern is useful when you hold millions of repeated strings, such as the keys of parsed JSON records: it stores one copy of each and makes dictionary lookups on them slightly faster.

Assignment binds, it never copies

Theory

b = a copies a pointer. After it, both names refer to the same object, and a change made through one name is visible through the other. This only matters for mutable objects (list, dict, set, most user classes). For immutable ones (int, str, tuple, frozenset) every "change" creates a new object, so sharing is invisible.

a = [1, 2, 3]
b = a
b.append(4)
print(a, a is b)

s = "abc"
t = s
t += "d"
print(s, t, s is t)
[1, 2, 3, 4] True
abc abcd False

+= is two different operations

x += y first tries x.__iadd__(y), which mutable types implement by changing x in place and returning it. If there is no __iadd__, it falls back to x = x + y, which builds a new object and rebinds the name. Either way the name is assigned at the end, and that last step explains one of the strangest results in Python:

t = ([1, 2], 3)
try:
    t[0] += [99]
except TypeError as e:
    print("error:", e)
print(t)
error: 'tuple' object does not support item assignment
([1, 2, 99], 3)

t[0] += [99] runs as: load t[0] (the list), call list.__iadd__, which appends 99 in place and succeeds, then store the result back with t[0] = …, which fails because tuples do not support item assignment. The exception is raised after the mutation has already happened.

tuple t (immutable) [0] 3 list (mutable) [1, 2, 99] 1. __iadd__ extends: OK 2. t[0] = result: TypeError The tuple still points at the same list, so the 99 stays.
Mutate, then fail. The in-place add succeeds on the list; only the final store into the tuple raises.

Copies: shallow and deep

list(a), a[:], a.copy() and copy.copy(a) make a shallow copy: a new outer container whose slots point at the same inner objects. copy.deepcopy recursively copies the inner objects too, and keeps a memo dict so that shared and cyclic references are copied once:

import copy
grid = [[0, 0], [0, 0]]
shallow = grid[:]
deep = copy.deepcopy(grid)
grid[0][0] = 1
print(shallow, deep)

row = [0] * 2
bad = [row] * 2
bad[0][0] = 7
good = [[0] * 2 for _ in range(2)]
good[0][0] = 7
print(bad, good)
[[1, 0], [0, 0]] [[0, 0], [0, 0]]
[[7, 0], [7, 0]] [[7, 0], [0, 0]]

[row] * 2 repeats the pointer to one list, so both rows are the same object. The comprehension evaluates [0] * 2 again on every iteration and builds independent rows.

Ints have no fixed size

Theory

A Python int never overflows. CPython stores the sign and magnitude as an array of 30-bit digits, so the cost of arithmetic grows with the number of digits: addition is linear, and multiplication uses Karatsuba above a threshold. Converting very large ints to and from decimal strings is quadratic, so since 3.11 CPython refuses conversions above 4300 digits by default to block denial-of-service inputs:

import sys
n = 2**100
print(n, n.bit_length(), sys.getsizeof(n))
print(sys.int_info.bits_per_digit, sys.get_int_max_str_digits())
big = 10**5000
try:
    str(big)
except ValueError as e:
    print("ValueError:", str(e)[:60])
print(len(hex(big)))
1267650600228229401496703205376 101 40
30 4300
ValueError: Exceeds the limit (4300 digits) for integer string conversio
4155

Hexadecimal conversion is linear, so it is not limited. You can raise the limit with sys.set_int_max_str_digits or the PYTHONINTMAXSTRDIGITS environment variable.

Booleans are ints: bool is a subclass of int, True == 1 and True + True == 2. That is why {1: "a", True: "b"} has a single key.

Concept and tricky questions

Drills

ConceptWhat two fields does every CPython object start with, and what is each one for?

Answer

Answer. ob_refcnt, the number of references to the object, and ob_type, a pointer to its type object.

Why. The refcount drives CPython's main memory management: when it reaches zero the object is deallocated at once. The type pointer is how the interpreter finds behaviour: len(x), x + y and x.attr all go through slots of type(x). Variable-size objects add ob_size after these two.

ConceptWhat is the difference between a is b and a == b? Which one can a class change?

Answer

Answer. is tests identity (the same object; in CPython, the same address). == tests equality by calling __eq__. A class can change == but never is.

Why. Identity is a pointer comparison done by the interpreter. Equality is a method call, so it can be anything: NumPy's == returns an array, and NaN is not equal to itself.

TrickyIs it safe to write if x is 0: or if name is "admin":? What does Python 3.8+ do when it sees this?

Answer

Answer. No. The result depends on caching and interning details that differ between versions, implementations and how the code was compiled. Since 3.8 the compiler emits SyntaxWarning: "is" with a literal. Did you mean "=="?

Why. x is 0 happens to be True for any x equal to 0 in CPython because 0 is in the small-int cache, but x is 1000 usually is not. Comparing strings with is works only when both happen to be interned.

Trickyid(a) == id(b) is True. Can a and b still be different objects?

Answer

Answer. Only if their lifetimes do not overlap. An id is unique among objects alive at the same moment, so if you saved id(a), let a die and then created b, b may reuse the address.

Why. CPython's id is the address. When an object is freed, pymalloc puts its block back on a free list, and the next object of the same size class often gets it. That is why id([]) == id([]) is usually True.

ConceptWhich integers does CPython cache, and when are two equal ints outside that range still the same object?

Answer

Answer. −5 to 256 are cached for the life of the interpreter. Equal ints outside the range are the same object when they are constants in the same code object (the compiler merges equal entries in co_consts), or when you pass the same object around.

Why. The cache exists because small ints are created constantly (loop counters, indexes, lengths). Constant merging is a separate, compile-time effect.

TrickyWhat does sys.getrefcount(None) print on 3.12, and why is it not the real number of references?

Answer

Answer. 4294967295 on a 64-bit build. None is immortal (PEP 683), so its count is a fixed sentinel that Py_INCREF and Py_DECREF do not change.

Why. Immortality removes the writes to ob_refcnt for objects that are used everywhere, which reduces cache-line traffic between cores and is a step towards the free-threaded build.

Conceptb = a where a is a list. Name every way to get an independent copy of the list, and say which ones also copy the items inside it.

Answer

Answer. a[:], list(a), a.copy(), copy.copy(a) and [*a] make shallow copies (a new list, the same item objects). Only copy.deepcopy(a) also copies the nested objects.

Why. A shallow copy duplicates the array of pointers. Whether that is enough depends on whether the items are mutable: a list of ints is fully independent after a shallow copy, a list of lists is not.

Predict the output and find the bug

Drills

OutputWhat does this print?

def add(x, item):
    x += [item]
    return x

a = [1]
b = add(a, 2)
c = (1,)
d = add(list(c), 2)
print(a, b, a is b, c, d)
Answer
[1, 2] [1, 2] True (1,) [1, 2]

Answer. The list passed in is extended in place, so a and b are the same object holding [1, 2]. list(c) is a fresh list, so the tuple c is untouched.

Why. x += [item] calls list.__iadd__, which mutates the caller's list. A function that wants to avoid this must write x = x + [item] or copy first.

OutputWhat does this print?

d = {1: "int", 1.0: "float", True: "bool"}
print(d)
print(len({0, False, 0.0, -0.0}))
Answer
{1: 'bool'}
1

Answer. 1, 1.0 and True are equal and hash the same, so they are one key. The first key object inserted (1) is kept and the value is overwritten twice, giving {1: 'bool'}. The set has one element.

Why. Dict and set membership use hash and ==. hash(1) == hash(1.0) == hash(True) == 1 and 0 == False == 0.0 == -0.0. When a key already exists, the dict keeps the old key object and replaces only the value.

OutputWhat does this print?

board = [[" "] * 3] * 3
board[1][1] = "X"
print(board)
Answer
[[' ', 'X', ' '], [' ', 'X', ' '], [' ', 'X', ' ']]

Answer. All three rows show the X, because [row] * 3 repeats one pointer three times.

Why. Sequence repetition copies references, not objects. Build independent rows with [[" "] * 3 for _ in range(3)].

OutputWhat does this print?

import math
nan = math.nan
values = [nan, 1.0]
print(nan in values, float("nan") in values, values.index(nan))
print(sorted([3.0, nan, 1.0, 2.0]))
Answer
True False 0
[3.0, nan, 1.0, 2.0]

Answer. nan in values is True because containment checks identity first, and the same math.nan object is in the list. A new NaN object is not found. index finds position 0 the same way. sorted cannot order NaN, and because every comparison with NaN is False, the result is not fully sorted.

Why. Sorting assumes a total order. With NaN in the list, the output depends on where NaN starts, so filter it out (or sort with a key that maps NaN to infinity) before sorting.

OutputWhat does this print?

print(True + True, True * 10, isinstance(True, int))
print(sum([True, False, True]), ["no", "yes"][True])
print(hash(True) == hash(1), True is 1 == True)
Answer
main.py:3: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
  print(hash(True) == hash(1), True is 1 == True)
2 10 True
2 yes
True False

Answer. bool is a subclass of int, so True behaves as 1 in arithmetic, indexing and hashing. The last expression is a chained comparison, (True is 1) and (1 == True), and True is 1 is False because they are different objects.

Why. Chained comparisons are a classic trap: a is b == c does not mean (a is b) == c. Python also emits a SyntaxWarning for is with a literal.

Find the bugThis function is meant to return a new list with x appended, leaving the caller's list alone. Find the bug.

def with_item(items, x):
    result = items
    result.append(x)
    return result

orig = [1, 2]
new = with_item(orig, 3)
print(orig, new)
Answer
[1, 2, 3] [1, 2, 3]

Answer. result = items only copies the reference, so append changes the caller's list. Fix: result = items.copy() (or return [*items, x]).

Why. Python passes references by value ("call by sharing"). The function cannot rebind the caller's name, but it can mutate the object the name points at.

Find the bugA config loader caches a default dict and hands it out. Users report that changing one config changes another. Find the bug.

DEFAULT = {"retries": 3, "hosts": ["a"]}

def load(overrides):
    cfg = dict(DEFAULT)
    cfg.update(overrides)
    return cfg

c1 = load({})
c1["hosts"].append("b")
c2 = load({})
print(c2["hosts"], DEFAULT["hosts"])
Answer
['a', 'b'] ['a', 'b']

Answer. dict(DEFAULT) is a shallow copy, so every config shares the same hosts list, and appending to it changes DEFAULT for all later loads. Fix: cfg = copy.deepcopy(DEFAULT).

Why. Shallow copies are fine for flat dicts of immutable values. As soon as a value is a list or dict, you need a deep copy or an immutable value (a tuple).

Find the bugThis check passes in tests that use small ids and fails in production. Why?

def same_user(a_id, b_id):
    return a_id is b_id

print(same_user(int("42"), int("42")))
print(same_user(int("4242"), int("4242")))
Answer
True
False

Answer. is compares identity. Ids up to 256 come from the small-int cache and are the same object; larger ids parsed from requests are different objects. Use ==.

Why. The tests passed by accident of caching. Linters (flake8 F632) and the compiler's SyntaxWarning catch the literal case, but not this one where both sides are variables.

Write the code

Coding

CodeWrite deep_size(obj) that returns an estimate of the total memory used by an object and everything it references through lists, tuples, sets, dicts and instance __dict__s. Count each distinct object once, even if it is shared or part of a cycle.

Estimated difficulty: about 1200 (Codeforces-style)

Answer

Walk the object graph with an explicit stack and a set of ids already counted, and add sys.getsizeof for each new object.

import sys

def deep_size(obj):
    seen = set()
    stack = [obj]
    total = 0
    while stack:
        o = stack.pop()
        if id(o) in seen:
            continue
        seen.add(id(o))
        total += sys.getsizeof(o)
        if isinstance(o, dict):
            stack.extend(o.keys())
            stack.extend(o.values())
        elif isinstance(o, (list, tuple, set, frozenset)):
            stack.extend(o)
        if hasattr(o, "__dict__"):
            stack.append(o.__dict__)
    return total

shared = list(range(10))
data = [shared, shared, {"k": shared}]
data.append(data)              # a cycle
print(sys.getsizeof(data), deep_size(data))
print(deep_size([]), deep_size([[]]))
120 762
56 120

The seen set makes shared and cyclic references count once, which is the same trick copy.deepcopy uses with its memo dict. Keeping ids is safe here because every object stays alive (it is reachable from obj) for the whole walk.

CodeWrite Sentinel(name), a class whose instances are unique markers you can test with is, such as MISSING = Sentinel("MISSING") for "no argument given". It should print as <MISSING>, stay a singleton per name, and survive copy.copy and copy.deepcopy as the same object.

Estimated difficulty: about 1300 (Codeforces-style)

Answer

Cache instances by name in __new__ and return self from the copy hooks.

import copy

class Sentinel:
    _registry = {}

    def __new__(cls, name):
        if name not in cls._registry:
            obj = super().__new__(cls)
            obj._name = name
            cls._registry[name] = obj
        return cls._registry[name]

    def __repr__(self):
        return f"<{self._name}>"

    def __bool__(self):
        return False

    def __copy__(self):
        return self

    def __deepcopy__(self, memo):
        return self

MISSING = Sentinel("MISSING")

def get(d, key, default=MISSING):
    if key in d:
        return d[key]
    if default is MISSING:
        raise KeyError(key)
    return default

print(MISSING, Sentinel("MISSING") is MISSING)
print(copy.deepcopy([MISSING])[0] is MISSING, get({}, "a", None))
<MISSING> True
True None

A sentinel beats None as a default when None is a valid value the caller might pass.

CodeWrite freeze(obj) that turns nested lists, dicts and sets into tuples, sorted tuples of key/value pairs and frozensets, so that the result can be used as a dict key or put in a set.

Estimated difficulty: about 1100 (Codeforces-style)

Answer

Recurse by type and convert each container to its immutable counterpart.

def freeze(obj):
    if isinstance(obj, dict):
        return tuple(sorted((k, freeze(v)) for k, v in obj.items()))
    if isinstance(obj, (list, tuple)):
        return tuple(freeze(x) for x in obj)
    if isinstance(obj, (set, frozenset)):
        return frozenset(freeze(x) for x in obj)
    hash(obj)   # raises TypeError for anything else that is unhashable
    return obj

cache = {}
key = freeze({"b": [1, 2], "a": {3, 4}})
cache[key] = "result"
print(key)
print(cache[freeze({"a": {4, 3}, "b": [1, 2]})])
(('a', frozenset({3, 4})), ('b', (1, 2)))
result

Sorting the dict items makes two dicts with the same contents but different insertion order produce the same key.

CodeShow, with a program, that a list's id stays the same while it grows but its item array moves. Use ctypes to read the list's internal ob_item pointer before and after many appends.

Estimated difficulty: about 1400 (Codeforces-style)

Answer

A PyListObject is PyObject_VAR_HEAD (refcount, type, size) followed by ob_item and allocated. With a 64-bit build those are at byte offsets 24 and 32.

import ctypes

def internals(lst):
    base = id(lst)
    ob_item = ctypes.c_void_p.from_address(base + 24).value
    allocated = ctypes.c_ssize_t.from_address(base + 32).value
    return ob_item, allocated

a = [0]
id_before = id(a)
item_before, cap_before = internals(a)
for i in range(10_000):
    a.append(i)
item_after, cap_after = internals(a)
print(id(a) == id_before, item_after != item_before)
print(cap_before, cap_after, len(a))
True True
1 10640 10001

The list object (and its id) never moves. Growing the list calls realloc on the separate pointer array, which may move it. That is why holding the id of a list is safe but holding a raw pointer into its items is not.

Cheat sheet

Theory
  • Every object starts with ob_refcnt and ob_type; variable-size ones add ob_size. A 64-bit object() is 16 bytes, an int 28.
  • Names hold references. Assignment, argument passing and return copy references, never objects.
  • is is identity (address in CPython), == calls __eq__. Use is only for None, True, False and your own sentinels.
  • Cached: ints −5..256, None, True, False, the empty tuple, and interned identifier-like string constants. In 3.12 these are immortal.
  • Equal constants in one code object are merged, so a = 257; b = 257 in one file gives a is b == True. Not a guarantee.
  • Containers check identity before equality, so nan in [nan] is True for the same NaN object.
  • x += y calls __iadd__ (in place for mutable types), then assigns. t[0] += [1] mutates the list and then raises.
  • Shallow copy: a[:], list(a), a.copy(). Deep copy: copy.deepcopy. [[0]*n]*m shares rows.
  • Ints are arbitrary precision in 30-bit digits; decimal conversion is limited to 4300 digits since 3.11.
  • bool subclasses int: True == 1, and 1, 1.0 and True are the same dict key.

Part A · The object model · Chapter 2

Names, scopes and closures

Python decides at compile time whether each name in a function is local, global, free or built in, and that one decision explains UnboundLocalError, the late-binding loop bug and the mutable-default-argument trap. This chapter covers the LEGB rule, how the compiler classifies names, how closures keep variables alive in cell objects, and what global and nonlocal really change.

TheoryDrillsCoding· about 3 hours· prerequisites: Chapter 1

Namespaces and the LEGB rule

Theory

A namespace maps names to objects. Python has four kinds that matter for name lookup, searched in this order:

  1. Local: the names assigned in the current function call.
  2. Enclosing: the locals of any function that lexically contains this one.
  3. Global: the module's namespace, a plain dict you can see as globals() or module.__dict__.
  4. Built-in: the builtins module, which holds len, print, ValueError and the rest.

Scopes are lexical: what matters is where the function was written, not where it is called from. A function defined inside another one can see the outer function's names even after the outer call has returned.

x = "global"

def outer():
    x = "enclosing"
    def inner():
        return x
    return inner

def caller():
    x = "caller's local"
    return outer()()

print(caller())
print(len.__module__, "len" in dir(__builtins__))
enclosing
builtins True

inner returns "enclosing" even though it is called from caller, because lookup follows the nesting in the source code.

Shadowing a built-in

Any assignment to a name such as list, id or sum at module level creates a global that hides the built-in for the rest of the module. Nothing warns you:

list = [1, 2, 3]
try:
    print(list("abc"))
except TypeError as e:
    print("TypeError:", e)
del list
print(list("abc"))
TypeError: 'list' object is not callable
['a', 'b', 'c']

del list removes the global, and lookup falls through to builtins again.

The compiler decides the scope

Theory

Python does not look a name up dynamically through all four scopes on every access. When it compiles a function, it reads the whole body and classifies every name once:

  • A name that is assigned anywhere in the function (by =, +=, for, import, def, class, with … as, except … as or a walrus :=) is local to the whole function, unless declared global or nonlocal.
  • A name that is only read, and is local in an enclosing function, is free: it is reached through a cell (below).
  • Any other name that is only read is global-or-built-in.

The classification picks the bytecode: LOAD_FAST for locals (an index into an array in the frame), LOAD_DEREF for free variables and LOAD_GLOBAL for the rest. LOAD_FAST is just an array index, which is why local variables are faster than globals.

import symtable

src = """
total = 0
def f(a):
    b = a + total
    def g():
        return b + len(a)
    return g
"""
mod = symtable.symtable(src, "<demo>", "exec")
f = mod.get_children()[0]
g = f.get_children()[0]
for table in (f, g):
    for s in sorted(table.get_symbols(), key=lambda s: s.get_name()):
        kind = ("local" if s.is_local() else "") + (" free" if s.is_free() else "") + (" global" if s.is_global() else "")
        print(table.get_name(), s.get_name(), kind.strip())
f a local
f b local
f g local
f total global
g a free
g b free
g len global

b is local in f and free in g. total and len are globals as far as the compiler knows; whether len is found in the module or in builtins is decided at run time.

UnboundLocalError

Because an assignment anywhere makes a name local everywhere in the function, reading it before the assignment fails, even if a global of the same name exists:

count = 0

def bump():
    try:
        count += 1
    except UnboundLocalError as e:
        print("UnboundLocalError:", e)

def show_then_set():
    try:
        print(count)
    except UnboundLocalError as e:
        print("UnboundLocalError:", e)
    count = 5

bump()
show_then_set()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

In show_then_set, the print is on the line before the assignment, and it still fails. The compiler saw count = 5 and made count local for the whole body. UnboundLocalError is a subclass of NameError.

Key idea

The rule is per function and decided at compile time: "assigned anywhere in this function, therefore local everywhere in this function". It is not "assigned before this line".

What bytecode the compiler chose

import dis

g_value = 10

def f(a):
    b = a * 2
    return b + g_value + len("x")

dis.dis(f)
  5           0 RESUME                   0

  6           2 LOAD_FAST                0 (a)
              4 LOAD_CONST               1 (2)
              6 BINARY_OP                5 (*)
             10 STORE_FAST               1 (b)

  7          12 LOAD_FAST                1 (b)
             14 LOAD_GLOBAL              0 (g_value)
             24 BINARY_OP                0 (+)
             28 LOAD_GLOBAL              3 (NULL + len)
             38 LOAD_CONST               2 ('x')
             40 CALL                     1
             48 BINARY_OP                0 (+)
             52 RETURN_VALUE

a and b are LOAD_FAST, while g_value and len use LOAD_GLOBAL, which checks the module dict and then the builtins dict. CPython caches global lookups per instruction and invalidates the cache when either dict changes, so in a hot loop LOAD_GLOBAL is cheap but still slower than LOAD_FAST.

Closures and cell objects

Theory

When an inner function uses a variable of the outer function, the outer local cannot live in the outer frame's array any more, because the inner function may outlive that frame. The compiler instead stores it in a cell object: a tiny heap object that holds one reference. The outer function and every inner function that uses the variable share the same cell, so they all see the latest value.

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    def peek():
        return count
    return inc, peek

inc, peek = make_counter()
inc(); inc()
print(peek())
print(inc.__code__.co_freevars, inc.__closure__[0] is peek.__closure__[0])
print(inc.__closure__[0].cell_contents)
print(make_counter.__code__.co_cellvars)
2
('count',) True
2
('count',)

co_cellvars lists the outer function's variables that live in cells, and co_freevars lists the inner function's references to them. __closure__ is the tuple of cells a function object carries.

make_counter frame (gone after return) count → cell cellcontents inc__closure__ = (cell,) peek__closure__ = (cell,) 2
One cell, shared. Both closures hold the same cell, so peek sees what inc wrote. The cell keeps the value alive after the outer frame is gone.

Late binding: the loop-closure bug

A closure captures the variable (the cell), not the value the variable had when the function was created. Functions created in a loop therefore all see the variable's final value:

fns = [lambda: i for i in range(3)]
print([f() for f in fns])

fixed = [lambda i=i: i for i in range(3)]
print([f() for f in fixed])

from functools import partial
def show(i):
    return i
parts = [partial(show, i) for i in range(3)]
print([p() for p in parts])
[2, 2, 2]
[0, 1, 2]
[0, 1, 2]

The default argument i=i is evaluated when each lambda is created, so it freezes the current value. functools.partial does the same by storing the argument. The same bug appears with callbacks registered in a loop (GUI handlers, threading.Thread(target=lambda: work(i))).

global and nonlocal

Theory

global x says "the name x in this function refers to the module's x". Assignments then write to the module dict. nonlocal x says "x refers to the nearest enclosing function's variable", and it must already exist there; it never reaches the module level.

total = 0

def add_global(n):
    global total
    total += n

def outer():
    level = "outer"
    def inner():
        nonlocal level
        level = "changed by inner"
    inner()
    return level

add_global(5)
print(total, outer())

try:
    exec("def f():\n    nonlocal missing\n")
except SyntaxError as e:
    print("SyntaxError:", e.msg)
5 changed by inner
SyntaxError: no binding for nonlocal 'missing' found

Neither statement is needed to mutate an object: items.append(x) inside a function works on a global list without global items, because it reads the name and calls a method; it never assigns the name. You need global or nonlocal only to rebind the name.

Interview trap

Mutable module-level state changed through global is hard to test and not thread-safe. In interviews, prefer an object with methods, a closure, or passing state in and returning it out.

Default arguments are evaluated once

Theory

def f(x, items=[]) evaluates [] once, when the def statement runs, and stores the object in f.__defaults__. Every call that does not pass items gets that same list, so changes accumulate between calls:

def append_to(x, items=[]):
    items.append(x)
    return items

print(append_to(1), append_to(2))
print(append_to.__defaults__)

def append_safe(x, items=None):
    if items is None:
        items = []
    items.append(x)
    return items

print(append_safe(1), append_safe(2))
[1, 2] [1, 2]
([1, 2],)
[1] [2]

Both calls return the same list object, which is why the first line prints [1, 2] twice: by the time print runs, the shared list holds both items. The None sentinel pattern creates a fresh list on each call. The same rule makes def f(when=datetime.now()) freeze the time at import, and it is also why the i=i trick above works.

Comprehensions and class bodies

A list comprehension has its own scope for its loop variable, so the variable does not leak. Since 3.12 (PEP 709), CPython inlines comprehensions into the enclosing function for speed, but it keeps the same visible semantics.

A class body is a namespace but not an enclosing scope for anything nested in it: methods cannot see class attributes as bare names, and neither can a comprehension in the class body (except in its first iterable, which is evaluated in the class scope):

class Config:
    base = 10
    try:
        scaled = [base * i for i in range(3)]
    except NameError as e:
        error = str(e)
    ok = [i for i in range(base, base + 2)]

print(Config.error)
print(Config.ok)

x = "module"
squares = [x for x in range(3)]
print(x, squares)
name 'base' is not defined
[10, 11]
module [0, 1, 2]

range(base, base + 2) is the first iterable, so it can see base. Inside the comprehension body, base is looked up as a global and not found. Use self.base, cls.base or Config.base inside methods.

Concept and tricky questions

Drills

ConceptState the LEGB rule. Is lookup decided by where a function is defined or where it is called?

Answer

Answer. Local, Enclosing, Global, Built-in, in that order. It is decided by where the function is defined (lexical scoping), not by the caller.

Why. The compiler knows the nesting of functions in the source and resolves local and free names at compile time. A call from another function does not add that function's locals to the search.

TrickyWhy does print(count) raise UnboundLocalError when it is the first line of a function, count is a global, and the assignment count = 5 comes later?

Answer

Answer. The compiler saw the assignment and made count a local of the whole function, so the first line reads an unset local slot.

Why. Scope is decided per function at compile time, not line by line at run time. Add global count to use the module variable, or rename one of them.

ConceptWhy are local variables faster to access than globals in CPython?

Answer

Answer. Locals are compiled to LOAD_FAST, an index into a fixed array in the frame. Globals use LOAD_GLOBAL, which must consult the module dict and then the builtins dict (with an inline cache that is invalidated when either dict changes).

Why. This is why old performance advice says to bind a global function to a local (append = result.append) before a tight loop. With the 3.11+ specialising interpreter the gap is smaller, but it still exists.

ConceptWhat is a cell object and when does CPython create one?

Answer

Answer. A small heap object holding one reference. CPython uses one for each local variable of a function that an inner function refers to, so the variable can outlive the outer frame and be shared by every closure that uses it.

Why. The outer function accesses it with LOAD_DEREF and STORE_DEREF, and each inner function carries the cell in __closure__.

TrickyDoes items.append(x) inside a function need global items when items is a module-level list? Does items = items + [x]?

Answer

Answer. append does not, because it only reads the name items and mutates the object. items = items + [x] does, because it assigns the name, which makes items local (and the read on the right then raises UnboundLocalError).

Why. global and nonlocal are about rebinding names, never about mutating objects.

ConceptWhen is the default value in def f(x=[]) evaluated, and where is it stored?

Answer

Answer. Once, when the def statement is executed. It is stored in f.__defaults__ (keyword-only defaults in f.__kwdefaults__).

Why. A def is an executable statement that builds a function object. Its default expressions run at that moment, so a mutable default is shared by every call.

TrickyCan nonlocal refer to a module-level variable?

Answer

Answer. No. nonlocal x must refer to a variable of an enclosing function. If no enclosing function defines x, it is a SyntaxError, even when a global x exists.

Why. Module-level names are reached with global. nonlocal exists for closures, where the variable lives in a cell.

Predict the output and find the bug

Drills

OutputWhat does this print?

callbacks = {}
for name in ["a", "b", "c"]:
    callbacks[name] = lambda: name.upper()
print([callbacks[k]() for k in "abc"])
Answer
['C', 'C', 'C']

Answer. Every lambda looks up name when it is called, after the loop finished, so all three return "C".

Why. At module level the lambdas read the global name. Inside a function they would share one cell. Either way the value is read late. Fix with lambda name=name: name.upper().

OutputWhat does this print?

def tag(x, seen=set()):
    seen.add(x)
    return len(seen)

print(tag("a"), tag("b"), tag("a"), tag("c", set()), tag("d"))
Answer
1 2 2 1 3

Answer. The default set is shared, so the counts grow 1, 2, 2. The explicit set() gives a fresh set (1). The next default call continues from the shared set and sees a, b, d (3).

Why. Passing an argument replaces the default only for that call; the stored default object is untouched.

OutputWhat does this print?

x = 1
def f():
    x = 2
    def g():
        return x
    x = 3
    return g

def h():
    global x
    x = 4

g = f()
h()
print(g(), x)
Answer
3 4

Answer. g reads the cell for f's x, which holds 3 by the time f returns. h rebinds the module x to 4. The output is 3 4.

Why. The closure sees the variable's latest value in the enclosing function, not the value at the moment g was defined.

OutputWhat does this print?

total = 0
results = [total := total + n for n in range(4)]
print(results, total)
Answer
[0, 1, 3, 6] 6

Answer. A walrus inside a comprehension binds in the containing scope, so total is updated on every step and keeps its final value: [0, 1, 3, 6] 6.

Why. PEP 572 makes := in a comprehension assign to the enclosing function or module scope, which makes running totals possible. The loop variable n itself still does not leak.

OutputWhat does this print?

def counter():
    n = 0
    def step():
        nonlocal n
        n += 1
        return n
    return step

a, b = counter(), counter()
print(a(), a(), b(), a.__closure__[0] is b.__closure__[0])
Answer
1 2 1 False

Answer. Each call to counter() creates a new frame and a new cell, so a and b count independently: 1 2 1 False.

Why. A cell is created per call of the outer function, not per definition. That is what makes closures usable as lightweight objects.

Find the bugThis retry helper builds one callback per attempt, but every callback logs the last attempt number. Find the bug and fix it.

def make_loggers(n):
    loggers = []
    for attempt in range(1, n + 1):
        def log():
            return f"attempt {attempt}"
        loggers.append(log)
    return loggers

print([f() for f in make_loggers(3)])
Answer
['attempt 3', 'attempt 3', 'attempt 3']

Answer. All the log functions share the cell for attempt, which ends at 3. Bind the value at creation time: def log(attempt=attempt):, or build each with a factory function such as def make(a): return lambda: f"attempt {a}".

Why. A factory call creates a new cell per attempt, which is the cleanest fix when you do not want an extra parameter on the callback.

Find the bugA timestamp default is wrong. Find the bug.

import time

def log(msg, when=time.monotonic()):
    return round(when, 3)

first = log("a")
time.sleep(0.05)
second = log("b")
print(first == second)
Answer
True

Answer. time.monotonic() is evaluated once, when the def runs, so every call reports the same time. Use when=None and set when = time.monotonic() inside the body when it is None.

Why. Defaults are values, not expressions re-evaluated per call. Tools such as ruff (B008) flag function calls in defaults for this reason.

Find the bugThis class tries to precompute a lookup table in its body and crashes on import. Why?

class Units:
    factor = 1000
    table = {name: factor * i for i, name in enumerate(["k", "M", "G"], 1)}

print(Units.table)
Answer
Traceback (most recent call last):
  File "main.py", line 1, in <module>
    class Units:
  File "main.py", line 3, in Units
    table = {name: factor * i for i, name in enumerate(["k", "M", "G"], 1)}
                   ^^^^^^
NameError: name 'factor' is not defined

Answer. The comprehension body cannot see class-scope names, so factor is looked up as a global and raises NameError. Compute the table after the class (Units.table = …), or pass the value into the first iterable, for example {name: f * i for f in [factor] for i, name in …}.

Why. The class body is a namespace, not an enclosing scope. Only the first iterable of a comprehension is evaluated in the class scope.

Write the code

Coding

CodeWrite a memoize decorator using only a closure (no functools). It should cache results by positional arguments, expose the cache as wrapped.cache, and keep the original function's name.

Estimated difficulty: about 1200 (Codeforces-style)

Answer

The cache dict lives in the decorator's frame and is captured by the wrapper.

def memoize(fn):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = fn(*args)
        return cache[args]
    wrapper.cache = cache
    wrapper.__name__ = fn.__name__
    wrapper.__doc__ = fn.__doc__
    wrapper.__wrapped__ = fn
    return wrapper

calls = 0
@memoize
def fib(n):
    global calls
    calls += 1
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(40), calls, fib.__name__, len(fib.cache))
102334155 41 fib 41

Because fib the global name now points at the wrapper, the recursive calls inside fib also go through the cache, which turns the exponential recursion into 41 real calls.

CodeWrite once(fn): a decorator that runs fn the first time it is called and returns the same result on every later call without running it again, even if the first call returned None.

Estimated difficulty: about 1300 (Codeforces-style)

Answer

Use a closure flag rather than testing the result, because None is a valid result.

def once(fn):
    done = False
    result = None
    def wrapper(*args, **kwargs):
        nonlocal done, result
        if not done:
            result = fn(*args, **kwargs)
            done = True
        return result
    return wrapper

@once
def init():
    print("initialising")
    return None

print(init(), init())
initialising
None None

nonlocal is needed because the wrapper rebinds both variables. For thread safety you would wrap the check in a threading.Lock.

CodeWrite make_accumulators(n) that returns n functions where the i-th adds i to its argument, then show that it is correct. Do it once with a default argument and once with a factory, and explain why the naive version fails.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
def naive(n):
    return [lambda x: x + i for i in range(n)]

def with_default(n):
    return [lambda x, i=i: x + i for i in range(n)]

def with_factory(n):
    def make(i):
        return lambda x: x + i
    return [make(i) for i in range(n)]

for build in (naive, with_default, with_factory):
    print(build.__name__, [f(100) for f in build(3)])
naive [102, 102, 102]
with_default [100, 101, 102]
with_factory [100, 101, 102]

The naive lambdas all share the comprehension's single i cell, which ends at 2. The default freezes the value per lambda (but exposes an extra parameter). The factory gives each lambda its own cell.

CodeWrite scope_report(source) that, for every function in a piece of source code, lists its local, free and global names, using the symtable module. Run it on a function with a nested closure.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
import symtable

def scope_report(source):
    def walk(table, depth=0):
        if table.get_type() == "function":
            groups = {"local": [], "free": [], "global": []}
            for s in table.get_symbols():
                if s.is_free():
                    groups["free"].append(s.get_name())
                elif s.is_local():
                    groups["local"].append(s.get_name())
                elif s.is_global():
                    groups["global"].append(s.get_name())
            parts = ", ".join(f"{k}={sorted(v)}" for k, v in groups.items() if v)
            print("  " * depth + f"{table.get_name()}: {parts}")
        for child in table.get_children():
            walk(child, depth + 1)
    walk(symtable.symtable(source, "<src>", "exec"))

scope_report("""
LIMIT = 10
def outer(data):
    seen = set()
    def check(x):
        if x in seen or x > LIMIT:
            return False
        seen.add(x)
        return True
    return [x for x in data if check(x)]
""")
  outer: local=['check', 'data', 'seen', 'x'], global=['set']
    check: local=['x'], free=['seen'], global=['LIMIT']

This is the same information the compiler uses to choose LOAD_FAST, LOAD_DEREF and LOAD_GLOBAL. Notice that seen is local in outer (it is also a cell variable) and free in check. The comprehension variable x appears as a local of outer because 3.12 inlines comprehensions into the enclosing function (PEP 709); it is still invisible outside the comprehension.

Cheat sheet

Theory
  • Lookup order: Local, Enclosing, Global, Built-in. Scoping is lexical (where the function is written).
  • Any assignment in a function (=, augmented assignment, for, import, def, class, as, :=) makes the name local to the whole function.
  • Reading a local before assigning it raises UnboundLocalError, even if a global exists.
  • Bytecode: LOAD_FAST (locals, array index), LOAD_DEREF (cells), LOAD_GLOBAL (module dict, then builtins).
  • Closures capture variables through shared cell objects (__closure__, co_freevars, co_cellvars), so they see the latest value.
  • Loop closures: bind per iteration with lambda i=i: …, functools.partial or a factory function.
  • global x rebinds the module variable. nonlocal x rebinds an enclosing function's variable and must find one. Neither is needed to mutate.
  • Defaults are evaluated once at def time and stored in __defaults__; use None for mutable or time-based defaults.
  • Comprehension variables do not leak; := inside a comprehension binds in the enclosing scope.
  • Class bodies are not enclosing scopes: methods and comprehensions in the class body cannot see class attributes by bare name.

Part B · Execution · Chapter 3

Bytecode and the eval loop

CPython does not run your source code directly. It compiles it to bytecode for a stack machine, stores that in code objects, and runs it in a big C loop that 3.11 and later rewrite on the fly into faster specialised instructions. This chapter walks the pipeline from source to bytecode, shows how to read dis output, explains frames and the value stack, and covers constant folding, zero-cost exceptions and the specialising interpreter.

TheoryDrillsCoding· about 3.5 hours· prerequisites: Chapters 1–2

From source to code object

Theory

When you import a module or run a script, CPython goes through these stages:

  1. Tokenise: split the text into tokens (NAME, OP, NUMBER, INDENT …).
  2. Parse: build an abstract syntax tree (AST) with the PEG parser introduced in 3.9.
  3. Symbol table: classify every name as local, free, cell or global (Chapter 2).
  4. Compile: turn the AST into a control-flow graph of instructions, optimise it, and assemble it into bytecode.
  5. Execute: the interpreter loop in Python/ceval.c runs the bytecode.

The result of step 4 is a code object: an immutable bundle of the bytecode plus everything needed to run it: constants, names, local variable names, the stack size it needs, line-number and exception tables. Every function, class body, module and comprehension has its own code object.

import ast, tokenize, io

src = "total = price * 3"
toks = [tokenize.tok_name[t.type] for t in tokenize.generate_tokens(io.StringIO(src).readline)]
print(toks[:5])
print(ast.dump(ast.parse(src).body[0]))
code = compile(src, "<demo>", "exec")
print(type(code).__name__, code.co_names, code.co_consts)
['NAME', 'OP', 'NAME', 'OP', 'NUMBER']
Assign(targets=[Name(id='total', ctx=Store())], value=BinOp(left=Name(id='price', ctx=Load()), op=Mult(), right=Constant(value=3)))
code ('price', 'total') (3, None)

compile() exposes steps 1–4 directly. Its mode argument is "exec" for a module or statements, "eval" for a single expression and "single" for one interactive statement (which prints expression results, as the REPL does).

What a code object holds

def area(w, h=2, *, unit="m"):
    scale = 1.0
    return f"{w * h * scale}{unit}"

c = area.__code__
print(c.co_name, c.co_argcount, c.co_kwonlyargcount, c.co_nlocals)
print(c.co_varnames)
print(c.co_consts, c.co_names, c.co_stacksize)
print(len(c.co_code), "bytes of bytecode")
area 2 1 4
('w', 'h', 'unit', 'scale')
(None, 1.0) () 2
30 bytes of bytecode

co_varnames lists the parameters first, then other locals, which is the order of the frame's local slots. A function object wraps a code object together with the things that vary per def execution: its globals dict, defaults, closure cells and __dict__. Many function objects can share one code object, for example the closures created by repeated calls of a factory.

Tip

The .pyc files in __pycache__ are simply the module's code object serialised with marshal, plus a header with a magic number (which changes with each bytecode format) and the source's modification time or hash. Chapter 12 covers when they are used.

Reading dis output

Theory

CPython bytecode is a sequence of 2-byte units: one byte of opcode and one byte of argument (larger arguments use EXTENDED_ARG prefixes). Many instructions are followed by cache entries that the specialising interpreter uses, which is why offsets in dis output sometimes jump by more than 2.

The machine is a stack machine: instructions push values onto a per-frame value stack and pop them off. a + b * c becomes "push a, push b, push c, multiply the top two, add the top two":

import dis

def calc(a, b, c):
    return a + b * c

dis.dis(calc)
  3           0 RESUME                   0

  4           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 LOAD_FAST                2 (c)
              8 BINARY_OP                5 (*)
             12 BINARY_OP                0 (+)
             16 RETURN_VALUE

Each line shows the source line number (only when it changes), the byte offset, the instruction name, the numeric argument and a human-readable meaning in brackets. BINARY_OP 5 (*) and BINARY_OP 0 (+) are one generic instruction for all binary operators, selected by the argument.

value stack after each instruction (top on the right) LOAD_FAST aa LOAD_FAST b, cabc BINARY_OP *ab*c BINARY_OP +a + b*c RETURN_VALUE pops it and returns
A stack machine. Operands are pushed, an operator pops its inputs and pushes its result. co_stacksize is the deepest this stack ever gets (3 here).

Control flow

if, while and for become conditional and unconditional jumps. A for loop calls iter() once (GET_ITER) and then FOR_ITER repeatedly, which calls next() and jumps out of the loop when the iterator is exhausted:

import dis

def count_pos(xs):
    n = 0
    for x in xs:
        if x > 0:
            n += 1
    return n

dis.dis(count_pos)
  3           0 RESUME                   0

  4           2 LOAD_CONST               1 (0)
              4 STORE_FAST               1 (n)

  5           6 LOAD_FAST                0 (xs)
              8 GET_ITER
        >>   10 FOR_ITER                13 (to 40)
             14 STORE_FAST               2 (x)

  6          16 LOAD_FAST                2 (x)
             18 LOAD_CONST               1 (0)
             20 COMPARE_OP              68 (>)
             24 POP_JUMP_IF_TRUE         1 (to 28)
             26 JUMP_BACKWARD            9 (to 10)

  7     >>   28 LOAD_FAST                1 (n)
             30 LOAD_CONST               2 (1)
             32 BINARY_OP               13 (+=)
             36 STORE_FAST               1 (n)
             38 JUMP_BACKWARD           15 (to 10)

  5     >>   40 END_FOR

  8          42 LOAD_FAST                1 (n)
             44 RETURN_VALUE

COMPARE_OP pushes the result of x > 0. In 3.12 the compiler lays the loop out so that POP_JUMP_IF_TRUE jumps forward to the increment, while the fall-through JUMP_BACKWARD goes straight back to FOR_ITER. The augmented assignment is BINARY_OP 13 (+=), the in-place variant of addition. END_FOR is where FOR_ITER jumps when the iterator raises StopIteration; the exception itself is never created for built-in iterators.

Frames and the eval loop

Theory

Each call to a Python function needs a frame: the state of one running code object. It holds the instruction pointer, the local variable slots (including cells), the value stack, and references to the function's globals and builtins. Since 3.11 CPython allocates these frames in a contiguous per-thread "data stack" rather than as separate heap objects, and only creates a full frame object when something asks for it (a traceback, sys._getframe, a debugger). That change is a large part of why 3.11 calls are faster.

import sys

def inner():
    f = sys._getframe()
    chain = []
    while f is not None:
        chain.append(f"{f.f_code.co_name}:{f.f_lineno}")
        f = f.f_back
    return chain

def middle():
    return inner()

def outer():
    return middle()

print(outer())
print(sys.getrecursionlimit())
['inner:7', 'middle:12', 'outer:15', '<module>:17']
1000

f_back links each frame to its caller, which is exactly the chain a traceback prints. The recursion limit (1000 by default) caps how deep this chain can grow so that a runaway recursion raises RecursionError instead of overflowing the C stack.

The loop itself

The core of ceval.c is conceptually:

for (;;) {
    opcode, oparg = next instruction
    switch (opcode) {
        case LOAD_FAST:  push(locals[oparg]); break;
        case BINARY_OP:  right = pop(); left = pop(); push(binary_op(left, right, oparg)); break;
        case RETURN_VALUE: return pop();
        ...
    }
    every so often: check for signals, pending calls and a GIL drop request
}

In reality it uses computed gotos where the compiler supports them (each instruction jumps straight to the next handler, which branch predictors handle better than a single switch), and the handlers are generated from a domain-specific language in Python/bytecodes.c. The "every so often" check is how a long-running loop still responds to Ctrl-C and lets other threads take the GIL (Chapter 11).

The specialising interpreter (3.11+)

Theory

PEP 659 made CPython's interpreter adaptive. Generic instructions such as LOAD_ATTR, BINARY_OP, LOAD_GLOBAL and CALL start in a counting mode. After an instruction has run a few times (the warm-up), the interpreter looks at the types it actually saw and replaces the instruction in place with a specialised version, using the inline cache entries after it to store what it learned (a type version, a dict key index, a slot offset):

import dis

class Point:
    def __init__(self, x):
        self.x = x

def get_x(p):
    return p.x

def add(a, b):
    return a + b

for _ in range(100):
    get_x(Point(1))
    add(1, 2)

dis.dis(get_x, adaptive=True)
dis.dis(add, adaptive=True)
  7           0 RESUME                   0

  8           2 LOAD_FAST                0 (p)
              4 LOAD_ATTR_INSTANCE_VALUE     0 (x)
             24 RETURN_VALUE
 10           0 RESUME                   0

 11           2 LOAD_FAST__LOAD_FAST     0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP_ADD_INT        0 (+)
             10 RETURN_VALUE

LOAD_ATTR_INSTANCE_VALUE reads the attribute straight from the instance's value array after checking that the type has not changed, skipping the general attribute-lookup machinery. BINARY_OP_ADD_INT adds two ints without the generic dispatch, and LOAD_FAST__LOAD_FAST is a "superinstruction" that does two loads in one step.

If the guess later turns out wrong (say add is suddenly called with strings), the specialised instruction fails its guard, falls back to the generic path, and after enough misses it is de-specialised and can re-specialise for the new types:

import dis

def add(a, b):
    return a + b

for _ in range(100):
    add(1, 2)
for _ in range(100):
    add("a", "b")

for ins in dis.get_instructions(add, adaptive=True):
    if ins.opname.startswith("BINARY_OP"):
        print(ins.opname)
BINARY_OP_ADD_UNICODE

Key idea

Type-stable code is faster in 3.11+. A function that is always called with the same types stays specialised. A function that sees ints, floats and strings at the same call site keeps falling back to the generic instructions. This is the same idea a JIT uses, applied inside the interpreter without generating machine code.

Compile-time optimisations

Theory

The compiler does some optimisation before any code runs:

  • Constant folding: expressions made only of constants are computed at compile time, as long as the result is small (to avoid building a 10 MB string at compile time).
  • Tuple constants: a literal list or set used only for membership (x in [1, 2, 3]) or iteration becomes a constant tuple or frozenset.
  • Dead code: code after return or inside if 0: is removed.
  • Jump threading and removal of redundant NOPs.
import dis

def consts(x):
    a = 24 * 60 * 60
    b = "ab" * 3
    c = x in [1, 2, 3]
    d = x in {1, 2, 3}
    if 0:
        print("never compiled")
    return a, b, c, d

print(consts.__code__.co_consts)
print([i.opname for i in dis.get_instructions(consts) if "CALL" in i.opname or "PRINT" in i.opname])
(None, 86400, 'ababab', (1, 2, 3), frozenset({1, 2, 3}))
[]

The constants already hold 86400, 'ababab', the tuple (1, 2, 3) and the frozenset, and no call to print survives.

Zero-cost exceptions

Before 3.11, entering a try block executed a SETUP_FINALLY instruction that pushed a block onto a stack, so even a try that never raised cost something. Since 3.11 the compiler instead records an exception table in the code object: ranges of instruction offsets and the handler to jump to. Entering a try block costs nothing; the table is consulted only when an exception is actually raised.

import dis

def safe_div(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

dis.dis(safe_div)
  3           0 RESUME                   0

  4           2 NOP

  5           4 LOAD_FAST                0 (a)
              6 LOAD_FAST                1 (b)
              8 BINARY_OP               11 (/)
             12 RETURN_VALUE
        >>   14 PUSH_EXC_INFO

  6          16 LOAD_GLOBAL              0 (ZeroDivisionError)
             26 CHECK_EXC_MATCH
             28 POP_JUMP_IF_FALSE        3 (to 36)
             30 POP_TOP

  7          32 POP_EXCEPT
             34 RETURN_CONST             0 (None)

  6     >>   36 RERAISE                  0
        >>   38 COPY                     3
             40 POP_EXCEPT
             42 RERAISE                  1
ExceptionTable:
  4 to 10 -> 14 [0]
  14 to 30 -> 38 [1] lasti
  36 to 36 -> 38 [1] lasti

The ExceptionTable at the bottom says "if anything between offsets 4 and 10 raises, jump to 14". There is no setup instruction at the start of the try (only a NOP that keeps the line number for tracing).

Interview trap

"Exceptions are slow, so check first" (look before you leap) is only half true in 3.11+. A try that does not raise is free. Raising and catching is still much slower than an if, so use exceptions for exceptional cases, not as the normal path of a hot loop.

Concept and tricky questions

Drills

ConceptName the stages CPython goes through between source text and running code.

Answer

Answer. Tokenising, parsing into an AST (PEG parser), building the symbol table, compiling the AST into bytecode (via a control-flow graph that is optimised and assembled), and executing that bytecode in the eval loop.

Why. Interviewers ask this to see whether you know Python is compiled (to bytecode) and then interpreted, and that .pyc files skip the first four stages on later imports.

ConceptWhat is the difference between a code object and a function object?

Answer

Answer. A code object is the immutable compiled body: bytecode, constants, names, variable names, stack size and line and exception tables. A function object wraps a code object with the run-time context of one def execution: its globals, defaults, closure cells, name and __dict__.

Why. Each call of a factory creates a new function object, but they all share one code object. That is how closures can be cheap.

TrickyIs CPython bytecode stable? Could you ship .pyc files built with 3.11 to users on 3.12?

Answer

Answer. No. Bytecode is an implementation detail and changes in almost every minor release (3.11 added specialisation and caches, 3.12 changed more instructions). Each .pyc header carries a magic number, and an interpreter ignores .pyc files whose magic number does not match.

Why. dis output in tutorials written for 3.8 looks different from 3.12 output for the same function. Answers that rely on specific opcodes should name the version.

ConceptWhat is a stack machine, and what does co_stacksize record?

Answer

Answer. A machine whose instructions take operands from and push results onto a value stack instead of naming registers. co_stacksize is the maximum depth that stack reaches for the code object, so the frame can allocate the right space up front.

Why. A stack machine makes the compiler simple: an expression tree turns into a post-order walk. The JVM and WebAssembly are also stack machines.

ConceptWhat does the specialising adaptive interpreter do, and which PEP introduced it?

Answer

Answer. PEP 659 (3.11). It replaces generic instructions (LOAD_ATTR, BINARY_OP, CALL, LOAD_GLOBAL …) in place with specialised versions based on the types observed at that instruction, storing guard data in inline caches, and de-specialises if the guards keep failing.

Why. It gives JIT-like gains for type-stable code without generating machine code, and it is the foundation the experimental JIT in 3.13 builds on.

TrickyDoes an empty try/except around a hot loop body slow down the loop in 3.12?

Answer

Answer. Not measurably when nothing is raised. Since 3.11, entering a try block executes no instruction; handlers are found through the code object's exception table only when an exception occurs.

Why. The cost moved entirely to the raising path, which is now somewhat more expensive because it searches the table.

ConceptWhy does x in [1, 2, 3] inside a function not build a list each time it runs?

Answer

Answer. The compiler sees that the list literal is only used for a membership test and replaces it with a constant tuple (1, 2, 3). A set literal used the same way becomes a constant frozenset, which makes the test O(1).

Why. This is a peephole optimisation on the bytecode. It only applies when every element is a constant.

Predict the output and find the bug

Drills

OutputWhat does this print?

def f():
    return 2 ** 10, "-" * 5, (1, 2) + (3,)

g = lambda: 2 ** 10
print(f.__code__.co_consts)
print(f.__code__.co_code == g.__code__.co_code, g.__code__.co_consts)
Answer
(None, (1024, '-----', (1, 2, 3)))
True (None, 1024)

Answer. Every element is a constant expression, so the compiler folds the whole return value into one constant tuple, (1024, '-----', (1, 2, 3)). Both functions then compile to the same two instructions (RESUME, RETURN_CONST 1), so their co_code bytes are equal; only their constant tables differ.

Why. Folding happens during compilation, before any code runs. Comparing co_code alone says nothing about what a function returns: the meaning is split between the bytecode and co_consts. The exact contents of co_consts (such as whether None is present) vary between versions.

OutputWhat does this print?

def f(a):
    b = len(a)
    return b + c

code = f.__code__
print(code.co_varnames, code.co_names)
try:
    f([1])
except NameError as e:
    print("NameError:", e)
Answer
('a', 'b') ('len', 'c')
NameError: name 'c' is not defined

Answer. co_varnames holds the locals ('a', 'b'), co_names holds the global names the bytecode uses, ('len', 'c'). The function compiles fine, and the missing global c is only noticed at run time.

Why. Python resolves globals when the LOAD_GLOBAL runs. A typo in a rarely-taken branch is not caught until that branch executes, which is one reason linters and type checkers are useful.

OutputWhat does this print?

import sys
sys.setrecursionlimit(60)

def depth(n=0):
    try:
        return depth(n + 1)
    except RecursionError:
        return n

print(depth() < 60)
Answer
True

Answer. True: the function catches the RecursionError at whatever depth hit the limit, and that depth is below 60 because the frames already on the stack (the module and print's caller chain) count too.

Why. The limit counts all Python frames on the current thread's stack, not just the recursive function's. Raising the limit very high risks crashing the C stack in pure C recursion paths, which is why it exists.

OutputHow many BINARY_OP instructions survive in this function, and which constant does the compiler create?

import dis

def seconds(days):
    return days * 24 * 60 * 60

ops = [i.argrepr for i in dis.get_instructions(seconds) if i.opname == "BINARY_OP"]
print(ops, seconds.__code__.co_consts)
Answer
['*', '*', '*'] (None, 24, 60)

Answer. Three multiplications, and no folded constant: days * 24 * 60 * 60 is parsed as ((days * 24) * 60) * 60, and none of those sub-expressions is all constants.

Why. Folding needs a sub-tree made entirely of constants. Writing days * (24 * 60 * 60) gives one multiplication by the folded 86400. The compiler will not reorder operations for you, because * can be overloaded and is not guaranteed to be associative.

Find the bugA developer "optimised" this function by checking for zero before dividing, to avoid the cost of try. In 3.12, which version is faster when b is almost never 0, and why?

import timeit

def lbyl(a, b):
    if b == 0:
        return None
    return a / b

def eafp(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

t1 = timeit.timeit(lambda: lbyl(10, 3), number=200_000)
t2 = timeit.timeit(lambda: eafp(10, 3), number=200_000)
print("eafp not slower:", t2 < t1 * 1.25)
Answer
eafp not slower: True

Answer. They are about the same, and eafp can be slightly faster, because a try that does not raise costs nothing in 3.11+ while lbyl always runs an extra comparison. The "optimisation" gains nothing when zero is rare.

Why. With zero-cost exceptions, the choice between the styles should be about clarity and how often the failure happens. If b == 0 were common, the if would win because raising is expensive.

Find the bugThis code tries to make a function faster by editing its bytecode-level constants and crashes. Why can you not assign to co_consts, and what is the supported way to get a modified code object?

def greet():
    return "hello"

try:
    greet.__code__.co_consts = (None, "hi")
except AttributeError as e:
    print("AttributeError:", e)

greet.__code__ = greet.__code__.replace(co_consts=tuple("hi" if c == "hello" else c for c in greet.__code__.co_consts))
print(greet())
Answer
AttributeError: readonly attribute
hi

Answer. Code objects are immutable, so their attributes are read-only. code.replace(...) (3.8+) returns a new code object with some fields changed, and a function's __code__ attribute can be reassigned.

Why. Immutability lets code objects be shared and cached safely. Tools such as coverage and some decorators use replace to build modified copies.

Write the code

Coding

CodeWrite opcode_histogram(fn) that returns a Counter of the instruction names used by a function, including the code objects of nested functions and comprehensions. Run it on a function that has a nested function.

Estimated difficulty: about 1200 (Codeforces-style)

Answer

Walk co_consts recursively, because nested code objects are stored there.

import dis, types
from collections import Counter

def opcode_histogram(fn):
    counts = Counter()
    stack = [fn.__code__]
    while stack:
        code = stack.pop()
        for ins in dis.get_instructions(code):
            counts[ins.opname] += 1
        stack.extend(c for c in code.co_consts if isinstance(c, types.CodeType))
    return counts

def sample(xs):
    def key(x):
        return -x
    return sorted([x * 2 for x in xs if x], key=key)

print(opcode_histogram(sample).most_common(4))
[('LOAD_FAST', 5), ('SWAP', 5), ('STORE_FAST', 4), ('RESUME', 2)]

Since 3.12 the comprehension is inlined into sample, so its loop instructions appear in sample's own code; the nested key function still has its own code object.

CodeWrite a tiny stack-machine interpreter for arithmetic expressions: compile a Python expression such as "(a + 2) * b" to a list of ("LOAD", name), ("CONST", value) and ("OP", symbol) instructions using ast, then execute it with an explicit stack.

Estimated difficulty: about 1500 (Codeforces-style)

Answer

A post-order walk of the AST emits instructions in stack order, which is exactly how CPython's compiler handles expressions.

import ast, operator

OPS = {ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/"}
FUNCS = {"+": operator.add, "-": operator.sub, "*": operator.mul, "/": operator.truediv}

def compile_expr(src):
    out = []
    def emit(node):
        if isinstance(node, ast.BinOp):
            emit(node.left)
            emit(node.right)
            out.append(("OP", OPS[type(node.op)]))
        elif isinstance(node, ast.Name):
            out.append(("LOAD", node.id))
        elif isinstance(node, ast.Constant):
            out.append(("CONST", node.value))
        else:
            raise SyntaxError(f"unsupported: {ast.dump(node)}")
    emit(ast.parse(src, mode="eval").body)
    return out

def run(code, env):
    stack, depth = [], 0
    for op, arg in code:
        if op == "LOAD":
            stack.append(env[arg])
        elif op == "CONST":
            stack.append(arg)
        else:
            right, left = stack.pop(), stack.pop()
            stack.append(FUNCS[arg](left, right))
        depth = max(depth, len(stack))
    return stack.pop(), depth

prog = compile_expr("(a + 2) * b - a / 4")
print(prog)
print(run(prog, {"a": 8, "b": 3}))
[('LOAD', 'a'), ('CONST', 2), ('OP', '+'), ('LOAD', 'b'), ('OP', '*'), ('LOAD', 'a'), ('CONST', 4), ('OP', '/'), ('OP', '-')]
(28.0, 3)

The second number is this program's equivalent of co_stacksize.

CodeWrite a decorator trace_lines that prints each line number of the decorated function as it executes, using sys.settrace. Show it on a small loop.

Estimated difficulty: about 1300 (Codeforces-style)

Answer

sys.settrace installs a global trace function that is called on each new frame. Returning a local trace function from the "call" event turns on "line" events for that frame.

import sys, functools

def trace_lines(fn):
    code = fn.__code__
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        lines = []
        def local(frame, event, arg):
            if event == "line":
                lines.append(frame.f_lineno - code.co_firstlineno)
            return local
        def global_trace(frame, event, arg):
            return local if frame.f_code is code else None
        old = sys.gettrace()
        sys.settrace(global_trace)
        try:
            return fn(*args, **kwargs), lines
        finally:
            sys.settrace(old)
    return wrapper

@trace_lines
def total(n):
    s = 0
    for i in range(n):
        s += i
    return s

print(total(3))
(3, [2, 3, 4, 3, 4, 3, 4, 3, 5])

Line offsets are relative to the def line. Debuggers and coverage tools are built on this hook (3.12 also adds the lower-overhead sys.monitoring API from PEP 669).

CodeShow the effect of specialisation with a measurement: time a function that is always called with ints against the same function called alternately with ints and floats, and print which BINARY_OP variant each ends up with.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
import dis, timeit

def stable(a, b, c):
    return a + b + c

def mixed(a, b, c):
    return a + b + c

for _ in range(1000):
    stable(1, 2, 3)
    mixed(1, 2, 3)
    mixed(1.5, 2.5, 3.5)

def variants(fn):
    return sorted({i.opname for i in dis.get_instructions(fn, adaptive=True) if i.opname.startswith("BINARY_OP")})

print(variants(stable), variants(mixed))
t_stable = timeit.timeit(lambda: stable(1, 2, 3), number=300_000)
t_mixed = timeit.timeit(lambda: (mixed(1, 2, 3), mixed(1.5, 2.5, 3.5)), number=150_000)
print("stable not slower:", t_stable <= t_mixed * 1.2)
['BINARY_OP_ADD_INT'] ['BINARY_OP_ADD_INT']
stable not slower: True

Both report BINARY_OP_ADD_INT. The mixed function specialised for ints first, and every float call then fails the instruction's type guard and runs the slow generic path. CPython only re-specialises after a run of misses, with an exponential back-off, so a call site whose types keep alternating never settles on a good specialisation. That is the real cost of mixing types, and the timing shows it.

The two functions are written separately on purpose. Specialisation is stored in the code object's bytecode, so two function objects that share one code object (for example closures made by the same factory) also share their specialisation state.

Cheat sheet

Theory
  • Pipeline: tokens → AST (PEG parser) → symbol table → CFG → bytecode in a code object → eval loop in ceval.c.
  • Code objects are immutable (co_code, co_consts, co_names, co_varnames, co_stacksize, co_exceptiontable); use code.replace() for a modified copy.
  • Function object = code object + globals + defaults + closure + __dict__.
  • CPython is a stack machine; dis.dis(fn) shows instructions, dis.dis(fn, adaptive=True) shows specialised ones.
  • 3.11+: frames live on a per-thread data stack; frame objects are created lazily for tracebacks and debuggers.
  • PEP 659 specialisation: LOAD_ATTR_INSTANCE_VALUE, BINARY_OP_ADD_INT, LOAD_GLOBAL_MODULE… guarded by type versions, de-specialised on repeated misses. Keep hot code type-stable.
  • Compile-time: constant folding (small results only), list/set literals in in become tuple/frozenset constants, dead code removed. No reordering of a * 24 * 60.
  • 3.11+ zero-cost try: an exception table replaces SETUP_FINALLY; raising is still expensive.
  • Bytecode and .pyc magic numbers change every minor version; never rely on them across versions.

Part B · Execution · Chapter 4

Memory management and garbage collection

CPython frees most objects the instant their last reference disappears, using reference counting, and runs a separate generational collector only to find cycles that reference counting cannot. Underneath both, a small-object allocator called pymalloc carves memory into arenas, pools and blocks. This chapter explains all three layers, when __del__ runs, how weak references avoid keeping objects alive, and why a Python process often does not give memory back to the OS.

TheoryDrillsCoding· about 3.5 hours· prerequisites: Chapters 1–3

Reference counting

Theory

Every object's ob_refcnt counts the references to it. The interpreter increments it (Py_INCREF) whenever a new reference is created, by binding a name, storing into a container or pushing onto the value stack, and decrements it (Py_DECREF) when a reference goes away. When the count reaches zero, Py_DECREF immediately calls the type's deallocator, which decrements everything the object refers to (possibly freeing those too) and returns the memory.

import sys

data = []
print(sys.getrefcount(data))      # +1 for the argument
alias = data
holder = [data, data]
print(sys.getrefcount(data))
del alias
holder.clear()
print(sys.getrefcount(data))
2
5
2

The consequences are what make CPython's memory behaviour predictable:

  • Deterministic destruction: an object without cycles is freed at the exact moment its last reference goes, so files closed in __del__ or by going out of scope are closed promptly (on CPython; other implementations do not promise this).
  • Cost on every operation: each reference change is a memory write, which is why immortal objects (Chapter 1) and the free-threaded build's biased refcounting matter.
  • Cycles leak: objects that refer to each other keep each other's counts above zero forever.
class Resource:
    def __init__(self, name):
        self.name = name
    def __del__(self):
        print("freed", self.name)

def work():
    r = Resource("local")
    print("returning")

work()
print("after call")
x = Resource("global")
x = None
print("after rebinding")
returning
freed local
after call
freed global
after rebinding

"freed local" is printed before "after call" because the frame's reference was the last one. Rebinding x frees the second object on the spot.

Interview trap

"Python is garbage collected, so objects are freed at some later point" is wrong for CPython in the common case. Without cycles, freeing is immediate and happens inside the statement that dropped the last reference. Relying on that is still bad style: use with for files, locks and connections so the code also works on PyPy.

The cycle collector

Theory

To reclaim cycles, CPython has a tracing garbage collector in the gc module. It only looks at container objects (lists, dicts, sets, instances, functions …) because only containers can form cycles. An int or a string can never be part of a cycle, so they are not tracked at all, and CPython even untracks containers that hold only atomic values:

import gc
print(gc.is_tracked(42), gc.is_tracked("text"))
print(gc.is_tracked([]), gc.is_tracked({}), gc.is_tracked({"a": 1}), gc.is_tracked({"a": []}))
t = tuple(range(3))
print(gc.is_tracked(t), end=" ")
gc.collect()
print(gc.is_tracked(t), gc.is_tracked(([],)))
False False
True False False True
True False True

An empty dict and a dict of ints are not tracked; a dict that holds a list is. Tuples are handled lazily: a new tuple starts tracked, and the collector untracks it the first time a collection finds that it holds only atomic values.

How it finds garbage

A collection works on a set of tracked containers:

  1. Copy each object's refcount into a scratch field gc_refs.
  2. For every object in the set, visit each object it references (tp_traverse) and subtract 1 from that target's gc_refs. After this, gc_refs counts only references coming from outside the set.
  3. Objects with gc_refs > 0 are definitely reachable from outside. Everything reachable from them is reachable too.
  4. Whatever is left is only referenced from inside the set: unreachable cycles. Run their finalizers, then break the cycles with tp_clear, and normal refcounting frees them.
import gc, weakref

class Node:
    def __init__(self, name):
        self.name = name
        self.other = None

a, b = Node("a"), Node("b")
a.other, b.other = b, a
probe = weakref.ref(a)
del a, b
print("alive after del:", probe() is not None)
print("collected:", gc.collect() >= 2)
print("alive after collect:", probe() is not None)
alive after del: True
collected: True
alive after collect: False
Node arefcnt 1 Node brefcnt 1 a.other b.other no names leftrefcounts never 0 gc: gc_refs = refcnt − internal refs = 1 − 1 = 0 for both ⇒ unreachable ⇒ freed
A cycle survives refcounting. Each node holds the other's only reference. The collector subtracts internal references and finds that nothing outside points in.

Generations

Most objects die young, so the collector is generational. New containers start in generation 0. Each collection that an object survives moves it to the next generation (0 → 1 → 2), and older generations are scanned less often:

  • Generation 0 is collected when (allocations − deallocations) of tracked containers exceeds threshold0 (700).
  • Generation 1 is collected after threshold1 (10) generation-0 collections, and generation 2 after threshold2 (10) generation-1 collections, with an extra rule: a full collection only runs if the number of objects waiting in the young generations is at least 25% of the long-lived ones, which keeps it from going quadratic in programs with millions of objects.
import gc
print(gc.get_threshold())
gc.collect()
print(gc.get_count())
junk = [[] for _ in range(500)]
print(gc.get_count()[0] >= 500)
gc.collect()
print(gc.get_stats()[2]["collections"] >= 1)
(700, 10, 10)
(0, 0, 0)
True
True

gc.get_count() shows how many allocations each generation has accumulated since its last collection. gc.freeze() (3.7+) moves everything into a permanent generation that is never scanned, which pre-fork servers use so that the collector does not touch, and therefore copy, pages shared with child processes.

Tip

Disabling the collector (gc.disable()) does not disable refcounting. Programs that create no cycles run fine without it, and some services disable it or raise the thresholds to avoid pauses. Any cycle created while it is off simply leaks until it is turned back on.

Finalizers and weak references

Theory

__del__ is a finalizer: it runs when the object is about to be destroyed, either because its refcount hit zero or because the collector is freeing its cycle. Since 3.4 (PEP 442) objects with __del__ inside cycles are collected normally; before that they were left in gc.garbage. A finalizer still has sharp edges:

  • It can run at any point where a reference is dropped, including in the middle of another operation or during interpreter shutdown, when module globals may already be None.
  • Exceptions raised in it are not propagated; they are printed to stderr as "Exception ignored in …".
  • It can resurrect the object by storing self somewhere. PEP 442 guarantees __del__ runs at most once per object.
class Bad:
    def __del__(self):
        raise RuntimeError("boom in __del__")

b = Bad()
del b
print("program continues")
Exception ignored in: <function Bad.__del__ at 0x…>
Traceback (most recent call last):
  File "main.py", line 3, in __del__
    raise RuntimeError("boom in __del__")
RuntimeError: boom in __del__
program continues

weakref.finalize is the safer tool: it registers a callback that runs when the object dies or at exit, does not keep the object alive, and runs at most once.

Weak references

A weak reference points at an object without increasing its refcount. Calling it returns the object while it is alive and None afterwards. Weak references are how caches and observer lists avoid keeping things alive just because they are cached:

import weakref, gc

class Image:
    def __init__(self, name):
        self.name = name

cache = weakref.WeakValueDictionary()
img = Image("logo.png")
cache["logo"] = img
print(list(cache.keys()))
del img
print(list(cache.keys()))

events = []
obj = Image("tmp")
weakref.finalize(obj, events.append, "finalized tmp")
del obj
print(events)
try:
    weakref.ref(42)
except TypeError as e:
    print("TypeError:", e)
['logo']
[]
['finalized tmp']
TypeError: cannot create weak reference to 'int' object

Not every type supports weak references: int, str, tuple and list do not, and a class with __slots__ needs "__weakref__" in its slots to support them.

pymalloc: arenas, pools and blocks

Theory

Python programs allocate huge numbers of small, short-lived objects, which general-purpose malloc handles poorly. CPython therefore sends every request of 512 bytes or less to its own allocator, pymalloc, and passes larger ones to the system malloc:

  • An arena is a 1 MiB chunk obtained from the OS with mmap (256 KiB before 3.10).
  • An arena is divided into pools of 16 KiB (4 KiB before 3.10). Each pool serves exactly one size class.
  • A pool is divided into blocks of its size class. Size classes are multiples of 16 bytes (16, 32, … 512), so there are 32 of them.

A request for 40 bytes is rounded up to the 48-byte class and served from a pool of 48-byte blocks. Freed blocks go onto that pool's free list and are reused for the next request of that class, which is very fast and explains the id reuse from Chapter 1.

arena · 1 MiB from mmap pool 16B pool 48B pool 64B empty pool … 64 pools one pool: header + blocks of 48 bytes hdr white blocks are on the pool's free list; an arena is returned to the OS only when all its pools are empty
Three levels. Requests up to 512 bytes are rounded to a 16-byte size class and served from a pool of that class.
import tracemalloc

tracemalloc.start()
before = tracemalloc.get_traced_memory()[0]
items = [str(i) * 3 for i in range(10_000)]
after = tracemalloc.get_traced_memory()[0]
print("about", (after - before) // 10_000, "bytes per item")
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")[0]
print(top.traceback[0].lineno, top.count >= 10_000)
tracemalloc.stop()
about 61 bytes per item
5 True

tracemalloc hooks the allocator and records where each block was allocated, which makes it the first tool to reach for when memory grows: take two snapshots and compare them with snapshot.compare_to.

Why memory is not returned to the OS

pymalloc can give an arena back to the OS only when every pool in it is empty. A program that allocates a million small objects and frees 99% of them may still hold most of its arenas, because a few surviving objects are scattered across them. The process's resident memory then stays high even though Python considers the memory free and will reuse it for new objects. Large allocations (over 512 bytes) go to the system malloc, which has its own rules about returning memory.

Key idea

"Memory did not go down after del" usually means fragmentation inside pymalloc or the C allocator, not a leak. A real leak shows up as memory that keeps growing under a steady workload; tracemalloc snapshot comparisons find it.

Concept and tricky questions

Drills

ConceptWhat are the two mechanisms CPython uses to reclaim memory, and what does each one handle?

Answer

Answer. Reference counting frees an object immediately when its count reaches zero, which handles nearly all objects. The generational cycle collector (gc) periodically finds groups of containers that reference each other but are unreachable from outside, which refcounting alone can never free.

Why. Refcounting gives prompt, predictable freeing; the collector exists only for cycles. Disabling gc keeps refcounting working.

TrickyWhy does the cycle collector not track ints and strings, and why might it stop tracking a dict?

Answer

Answer. Ints and strings hold no references to other objects, so they can never be part of a cycle. CPython also untracks containers whose contents are all atomic (a dict of ints, a tuple of strings) because they cannot be in a cycle either, which makes collections cheaper.

Why. The cost of a collection is proportional to the number of tracked objects. Untracking is an optimisation; gc.is_tracked shows it.

ConceptExplain how the collector decides that a group of objects is unreachable.

Answer

Answer. It copies each object's refcount, then subtracts one for every reference that comes from another object in the same generation. Objects left with a positive count are referenced from outside and are roots; everything reachable from them is alive. What remains is referenced only from inside the group and is garbage.

Why. This avoids having to know all the program's roots (C stacks, extension modules): any reference the collector cannot explain must come from outside.

ConceptWhat are the default gc thresholds and what does each number mean?

Answer

Answer. (700, 10, 10). Generation 0 is collected when net allocations of tracked containers exceed 700. Generation 1 is collected every 10 generation-0 collections, and generation 2 every 10 generation-1 collections (and only when enough new long-lived objects have piled up).

Why. Young objects die quickly, so frequent cheap young collections plus rare full ones give the best trade-off.

TrickyIs it true that a cycle containing an object with __del__ is never collected?

Answer

Answer. Not since Python 3.4. PEP 442 allows the collector to call finalizers of objects in a cycle and then free them. Before 3.4 such cycles were put in gc.garbage and leaked.

Why. It is still true that __del__ order inside a cycle is undefined, and a finalizer can see other objects of the cycle already cleared.

ConceptWhat is a weak reference and give two uses.

Answer

Answer. A reference that does not increase the target's refcount, so it does not keep the target alive. Calling it returns the object or None once it has been freed. Uses: caches (WeakValueDictionary) that should not keep entries alive, observer or callback registries, and parent pointers in trees to avoid cycles.

Why. The weak reference is cleared (and its callback run) as part of the target's deallocation.

TrickyA service frees most of a large data structure, yet its RSS stays high. Name two reasons that are not leaks.

Answer

Answer. pymalloc can only return an arena to the OS when all 64 of its pools are empty, so a few survivors pin whole arenas. The C allocator (glibc malloc) used for larger blocks also keeps freed memory in its own free lists and heap rather than returning it.

Why. The memory is free from Python's point of view and will be reused. A leak shows as growth under a steady load; check with tracemalloc.

Predict the output and find the bug

Drills

OutputWhat does this print, and in what order?

class T:
    def __init__(self, n):
        self.n = n
    def __del__(self):
        print("del", self.n)

def f():
    a = T(1)
    b = T(2)
    print("end of f")

f()
c = [T(3), T(4)]
c = None
print("done")
Answer
end of f
del 1
del 2
del 4
del 3
done

Answer. Both locals of f are freed when its frame is torn down, after "end of f". Clearing a frame's locals releases them in order, so 1 then 2. Rebinding c frees the list, and the list's deallocator releases its items from the end, so 4 then 3, before "done".

Why. CPython's list deallocation walks the item array backwards. These orders are CPython implementation details; only "before done" is something you could reasonably rely on, and even that only without cycles.

OutputWhat does this print?

import gc, weakref

class A:
    pass

gc.disable()
a = A()
a.self = a
r = weakref.ref(a)
del a
print(r() is None)
gc.enable()
gc.collect()
print(r() is None)
Answer
False
True

Answer. False then True. With the collector disabled the self-cycle keeps the object alive; refcounting alone cannot free it. After a collection it is gone.

Why. a.self = a gives the object a reference to itself through its __dict__, so its count never reaches zero.

OutputWhat does this print?

saved = []

class Phoenix:
    def __del__(self):
        print("__del__ runs")
        saved.append(self)

p = Phoenix()
del p
print(len(saved))
saved.clear()
print("cleared")
Answer
__del__ runs
1
cleared

Answer. __del__ runs once when p is deleted and stores self, resurrecting the object. When saved.clear() drops it again, __del__ does not run a second time.

Why. PEP 442 marks an object as finalized after its first __del__ call. Resurrection is legal but almost always a bug.

OutputWhat does this print?

import weakref

class Key:
    def __init__(self, n):
        self.n = n

meta = weakref.WeakKeyDictionary()
k1, k2 = Key(1), Key(2)
meta[k1] = "first"
meta[k2] = "second"
del k1
print(sorted(v for v in meta.values()))
Answer
['second']

Answer. ['second']: the entry for k1 disappears as soon as k1's last strong reference goes.

Why. WeakKeyDictionary is the standard way to attach extra data to objects you do not own without keeping them alive, for example per-request metadata.

Find the bugThis tree leaks memory in a long-running service that builds and drops many trees. Find the cause and fix it without disabling gc.

import gc, weakref

class Node:
    def __init__(self, parent=None):
        self.parent = parent
        self.children = []
        if parent:
            parent.children.append(self)

root = Node()
child = Node(root)
probe = weakref.ref(root)
del root, child
print("freed without gc:", probe() is None)
Answer
freed without gc: False

Answer. Each child holds a strong reference to its parent and the parent holds the child, so every tree is a cycle that only the collector can free, and until it runs the trees pile up. Store the parent as a weak reference: self._parent = weakref.ref(parent) if parent else None with a parent property that calls it. Then dropping the root frees the whole tree by refcounting.

Why. Parent pointers are the most common source of cycles. Weak back-references make freeing immediate and cut gc work.

Find the bugA temporary file wrapper deletes the file in __del__. On PyPy and sometimes at interpreter exit, files are left behind. What is wrong and what should replace it?

import os, tempfile, weakref

class TempFile:
    def __init__(self):
        fd, self.path = tempfile.mkstemp()
        os.close(fd)
        self._finalizer = weakref.finalize(self, os.remove, self.path)

    def close(self):
        self._finalizer()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

with TempFile() as t:
    p = t.path
print(os.path.exists(p))
Answer
False

Answer. __del__ timing is not guaranteed: other implementations free objects later, objects in cycles wait for the collector, and at shutdown globals such as os may already be gone. The fixed version above uses weakref.finalize, which is guaranteed to run at most once and also at exit, and gives a context manager for deterministic cleanup.

Why. Use explicit cleanup (with) as the main path and a finalizer only as a safety net.

Find the bugA function caches results in a module-level dict keyed by request object, and memory grows forever. Why, and what is the fix?

import weakref, gc

class Request:
    pass

_cache = {}

def compute(req):
    if req not in _cache:
        _cache[req] = object()
    return _cache[req]

for _ in range(1000):
    compute(Request())
print(len(_cache))

_wcache = weakref.WeakKeyDictionary()
def compute_weak(req):
    if req not in _wcache:
        _wcache[req] = object()
    return _wcache[req]

for _ in range(1000):
    compute_weak(Request())
print(len(_wcache))
Answer
1000
0

Answer. The dict holds a strong reference to every request ever seen, so none can be freed. A WeakKeyDictionary drops each entry as soon as the request object dies.

Why. Unbounded caches are the most common real "leak" in Python services. Use weak keys, or a bounded cache such as functools.lru_cache(maxsize=…).

Write the code

Coding

CodeWrite a context manager gc_report() that, while active, records every collection the cycle collector runs (generation, objects collected) using gc.callbacks, and prints a summary on exit. Use it around code that creates many cycles.

Estimated difficulty: about 1300 (Codeforces-style)

Answer

gc.callbacks receives ("start" | "stop", info) for every collection.

import gc
from contextlib import contextmanager

@contextmanager
def gc_report():
    runs = []
    def cb(phase, info):
        if phase == "stop":
            runs.append((info["generation"], info["collected"]))
    gc.callbacks.append(cb)
    try:
        yield runs
    finally:
        gc.callbacks.remove(cb)
        by_gen = {g: sum(1 for x, _ in runs if x == g) for g in (0, 1, 2)}
        collected = sum(c for _, c in runs)
        print(f"collections per generation: {by_gen}, objects collected: {collected}")

class Pair:
    def __init__(self):
        self.me = self

with gc_report():
    for _ in range(20_000):
        Pair()
collections per generation: {0: 26, 1: 3, 2: 0}, objects collected: 19809

Every Pair is a self-cycle, so refcounting frees none of them. They are reclaimed by generation-0 collections triggered every 700 net allocations; the last few hundred are still waiting in generation 0 when the block ends, which is why the total is a little under 20,000. Exact counts vary slightly between runs and versions.

CodeWrite LRUWeakCache(maxsize): a cache that keeps strong references to the maxsize most recently used values (so they stay alive) but still returns any older value that is alive elsewhere in the program, using a weak map.

Estimated difficulty: about 1400 (Codeforces-style)

Answer

Combine an OrderedDict for the strong, bounded part with a WeakValueDictionary for everything.

import weakref
from collections import OrderedDict

class LRUWeakCache:
    def __init__(self, maxsize):
        self.maxsize = maxsize
        self.strong = OrderedDict()
        self.weak = weakref.WeakValueDictionary()

    def get(self, key):
        value = self.weak.get(key)
        if value is not None:
            self._touch(key, value)
        return value

    def put(self, key, value):
        self.weak[key] = value
        self._touch(key, value)

    def _touch(self, key, value):
        self.strong[key] = value
        self.strong.move_to_end(key)
        while len(self.strong) > self.maxsize:
            self.strong.popitem(last=False)

class Blob:
    def __init__(self, name):
        self.name = name

cache = LRUWeakCache(2)
held = Blob("held")
cache.put("held", held)
for name in ["a", "b", "c"]:
    cache.put(name, Blob(name))
print([k for k in ["held", "a", "b", "c"] if cache.get(k)])
['held', 'c']

held fell out of the strong part when b was added, but it is still alive through the variable held, so the weak map returns it, and looking it up moves it back into the strong part. That evicts b, and since nothing else refers to b it is freed at once. a was evicted earlier and freed the same way. Only held and c are left.

CodeUse tracemalloc to find which line of a function allocates the most memory. Write top_allocations(fn, n) that runs fn and returns the n source lines with the most bytes still allocated afterwards.

Estimated difficulty: about 1200 (Codeforces-style)

Answer
import tracemalloc

def top_allocations(fn, n=2):
    tracemalloc.start()
    try:
        result = fn()
        snap = tracemalloc.take_snapshot()
    finally:
        tracemalloc.stop()
    stats = snap.filter_traces([tracemalloc.Filter(True, __file__)]).statistics("lineno")
    return result, [(s.traceback[0].lineno, s.size // 1024) for s in stats[:n]]

def build():
    small = [i * 1000 for i in range(1000)]
    big = [str(i) * 20 for i in range(50_000)]
    return small, big

result, top = top_allocations(build)
print(top[0][0], top[0][1] > top[1][1], len(top))
15 True 2

Line 15 (big = …) comes out on top. Keeping result alive matters: memory freed before the snapshot does not appear in it, which is why build returns the lists instead of their lengths. To find a leak, take a snapshot before and after a repeated operation and use after.compare_to(before, "lineno").

CodeShow pymalloc's size classes: write a program that allocates many objects whose sizes fall on either side of a 16-byte boundary and uses tracemalloc to show that the memory per object jumps in steps of 16 bytes.

Estimated difficulty: about 1500 (Codeforces-style)

Answer

Bytes objects of length n need sys.getsizeof(b"") + n bytes; tracemalloc reports the requested size, while pymalloc rounds it up to the next multiple of 16. Measure the arena usage indirectly through sys.getsizeof and the size class formula.

import sys

def size_class(nbytes):
    return (nbytes + 15) // 16 * 16

base = sys.getsizeof(b"")
for n in [0, 14, 15, 16, 30, 31, 32]:
    request = sys.getsizeof(b"x" * n)
    print(f"len {n:2}: request {request:3} bytes -> block {size_class(request):3}")
print("small-object limit:", 512, "bytes; classes:", 512 // 16)
len  0: request  33 bytes -> block  48
len 14: request  47 bytes -> block  48
len 15: request  48 bytes -> block  48
len 16: request  49 bytes -> block  64
len 30: request  63 bytes -> block  64
len 31: request  64 bytes -> block  64
len 32: request  65 bytes -> block  80
small-object limit: 512 bytes; classes: 32

A bytes of length 15 requests 48 bytes and fits a 48-byte block exactly; length 16 requests 49 and needs a 64-byte block, wasting 15 bytes. This internal waste is what sys._debugmallocstats() reports as "bytes lost to quantization".

Cheat sheet

Theory
  • Refcounting frees an object the moment its count hits zero; deallocation cascades to what it referenced.
  • Only containers are tracked by gc; containers of atomic values may be untracked (gc.is_tracked).
  • Cycle detection: subtract internal references from refcount copies; whatever has zero external references is garbage.
  • Generations 0/1/2 with thresholds (700, 10, 10); full collections also need 25% growth of long-lived objects. gc.freeze() for pre-fork servers.
  • gc.disable() stops only cycle collection; refcounting still runs.
  • __del__: runs at most once (PEP 442), may resurrect, exceptions are only printed, timing not guaranteed outside CPython. Prefer with plus weakref.finalize.
  • Weak references: weakref.ref, WeakValueDictionary, WeakKeyDictionary, WeakSet. int, str, tuple, list cannot be weakly referenced; __slots__ classes need __weakref__.
  • pymalloc: requests ≤ 512 bytes, 32 size classes of 16 bytes, 16 KiB pools, 1 MiB arenas (3.10+). Arenas go back to the OS only when completely empty.
  • Find growth with tracemalloc snapshots and compare_to; RSS staying high after frees is usually fragmentation.

Part C · Data structures inside · Chapter 5

How the built-in containers work

"Use a dict, lookups are O(1)" is the start of the answer, not the answer. This chapter opens up list, tuple, dict, set and deque: how each is laid out in memory, how it grows, what its operations really cost, and where the worst cases come from. You will see the over-allocation pattern of lists, the compact ordered dict with its separate index table, why sets are not ordered, and why hash(-1) is -2.

TheoryDrillsCoding· about 4 hours· prerequisites: Chapters 1 and 4

list: a dynamic array of pointers

Theory

A list object is a header plus a pointer, ob_item, to a separate C array of PyObject*. ob_size is the number of items in use and allocated is the capacity of the array. Items are never stored inline; a list of a million ints is a million pointers to int objects.

When an append finds the array full, CPython grows it to roughly 1.125 times the new size plus a small constant, rounded to a multiple of 4. In 3.12 the formula in list_resize is (newsize + (newsize >> 3) + 6) & ~3. Growing by a fixed factor is what makes append amortised O(1): the total copying done over n appends is proportional to n.

import sys

items = []
caps = []
last = None
for i in range(80):
    cap = (sys.getsizeof(items) - sys.getsizeof([])) // 8
    if cap != last:
        caps.append(cap)
        last = cap
    items.append(i)
print(caps)
[0, 4, 8, 16, 24, 32, 40, 52, 64, 76, 92]

Each capacity is the size reached when the previous one filled up. The small 1.125 factor wastes little memory, at the cost of more frequent reallocations than, say, the factor of 2 many C++ standard libraries use for std::vector.

What each operation costs

OperationCostWhy
a[i], a[i] = x, len(a)O(1)Index into the pointer array
a.append(x), a.pop()O(1) amortisedUse spare capacity at the end
a.insert(0, x), a.pop(0)O(n)Shift every pointer one slot (memmove)
x in a, a.index(x), a.remove(x)O(n)Linear scan calling ==
a[i:j]O(j − i)New list, copies the pointers
a.sort()O(n log n)Timsort: stable, O(n) on already-sorted runs
a + b, a * kO(len of result)New list
import timeit
from collections import deque

n = 50_000
t_list = timeit.timeit("while a: a.pop(0)", setup=f"a = list(range({n}))", number=1)
t_deque = timeit.timeit("while a: a.popleft()", setup=f"from collections import deque; a = deque(range({n}))", number=1)
print("list.pop(0) at least 5x slower:", t_list > 5 * t_deque)
list.pop(0) at least 5x slower: True

Using a list as a FIFO queue turns a linear algorithm quadratic. collections.deque is the fix (below).

Timsort

list.sort and sorted use Timsort (since 2.3; the merge policy was improved to "powersort" in 3.11). It scans for runs that are already ascending (or strictly descending, which it reverses), extends short runs to a minimum length with binary insertion sort, and merges runs using a stack with rules that keep merges balanced. Merging uses galloping: when one run keeps winning, it switches to exponential search to copy whole chunks. The result is stable, O(n) on sorted or reverse-sorted input, and very fast on partially ordered real-world data.

records = [("bob", 2), ("amy", 1), ("cat", 2), ("dan", 1)]
by_score = sorted(records, key=lambda r: r[1])
print(by_score)
print(sorted(sorted(records), key=lambda r: r[1]) == sorted(records, key=lambda r: (r[1], r[0])))
[('amy', 1), ('dan', 1), ('bob', 2), ('cat', 2)]
True

Stability means equal keys keep their input order, so sorting by a secondary key first and then by the primary key gives a correct multi-key sort.

tuple: fixed-size and inline

Theory

A tuple stores its item pointers inside the object, right after the header, because its size never changes. That saves the separate allocation a list needs and makes a tuple smaller:

import sys
print(sys.getsizeof((1, 2, 3)), sys.getsizeof([1, 2, 3]))
print(sys.getsizeof(()), sys.getsizeof((1,)))
empty_a, empty_b = (), tuple()
print(empty_a is empty_b, tuple([]) is empty_a)
64 88
40 48
True True

The empty tuple is a singleton. CPython also keeps free lists of recently freed tuples for each length below 20, so creating and destroying small tuples (as function arguments and multiple returns do) rarely touches the allocator.

"Immutable" means the tuple's slots cannot be rebound. The objects in them can still be mutable, and a tuple is only hashable if all its items are:

t = (1, [2, 3])
t[1].append(4)
print(t)
try:
    hash(t)
except TypeError as e:
    print("TypeError:", e)
print(hash((1, (2, 3))) == hash((1, (2, 3))))
(1, [2, 3, 4])
TypeError: unhashable type: 'list'
True

dict: the compact ordered hash table

Theory

Since 3.6 (and guaranteed by the language since 3.7), a dict keeps keys in insertion order. CPython does it with a layout called the compact dict, which uses two arrays:

  • An index table of size 2k, where each slot holds a small integer (1, 2, 4 or 8 bytes depending on the table size) pointing into the entries array, or a marker for "empty" or "deleted".
  • A dense entries array of (hash, key, value) triples, appended to in insertion order.

A lookup hashes the key, uses the low bits to pick an index slot, follows it to an entry, and compares the stored hash and then the key with is and ==. On a collision it probes another slot. Iteration just walks the entries array, which is why it is ordered and fast.

indices (8 slots, 1 byte each) – 1 – 0 – 2 – – entries (dense, insertion order) 0: hash("x") "x" → 10 1: hash("y") "y" → 20 2: hash("z") "z" → 30 slot = hash & 7, then probe with the perturbed recurrence on collisions; iteration walks the entries array
Two arrays. The sparse part holds only small integers, so leaving a third of it empty costs little; the 24-byte entries are stored densely.

The compact layout uses less memory than the old one (which kept a full 24-byte entry in every slot, mostly empty), and it keeps ordering for free.

Probing, load factor and resizing

CPython uses open addressing: all entries live in the one table, and a collision is resolved by probing other slots. The probe sequence is i = (5*i + 1 + perturb) mod size, with perturb starting as the full hash and shifted right by 5 bits each step, so every bit of the hash eventually influences the probe, not just the low bits.

The table is kept at most two-thirds full. When an insert would pass that, the dict is resized to the next power of two that leaves room (roughly 3× the number of items) and every entry is re-inserted:

import sys

d = {}
last = sys.getsizeof(d)
print(0, last)
for i in range(50):
    d[i] = i
    size = sys.getsizeof(d)
    if size != last:
        print(len(d), size)
        last = size
0 64
1 224
6 352
11 632
22 1168
43 2264

A dict never shrinks when you delete keys. Deleted entries become "dummy" markers so that probe chains stay intact, and the space is reclaimed only on the next resize, by clear(), or by building a new dict:

import sys
d = {i: i for i in range(1000)}
full = sys.getsizeof(d)
for i in range(990):
    del d[i]
print(sys.getsizeof(d) == full, len(d))
print(sys.getsizeof(dict(d)) < full)
True 10
True

Hashing

hash(x) must satisfy one rule: if a == b then hash(a) == hash(b). The reverse is not required; different objects may share a hash (a collision) and only cost extra probes. A few details show up in interviews:

print(hash(1), hash(1.0), hash(True))
print(hash(-1), hash(-2))
print(hash(2**61 - 1), hash(2**61))
print(hash(float("inf")), hash(0.5))
1 1 1
-2 -2
0 1
314159 1152921504606846976
  • Numbers that are equal hash equally across types, so 1, 1.0 and True are one dict key.
  • Integer hashes are computed modulo the Mersenne prime 261 − 1, so huge ints wrap around.
  • -1 is reserved as the error return value of the C-level hash function, so hash(-1) is replaced with -2.
  • str and bytes hashes are randomised per process (SipHash with a random key) to stop attackers from crafting keys that all collide. Set PYTHONHASHSEED to make them repeatable.

A type with a constant __hash__ turns every insertion into a long probe chain, and the dict degrades to O(n) per operation:

import timeit

class Key:
    def __init__(self, v):
        self.v = v
    def __eq__(self, other):
        return self.v == other.v

class BadKey(Key):
    def __hash__(self):
        return 42

class GoodKey(Key):
    def __hash__(self):
        return hash(self.v)

for cls in (GoodKey, BadKey):
    t = timeit.timeit(lambda: {cls(i): i for i in range(400)}, number=5)
    print(cls.__name__, "slow" if t > 0.05 else "fast")
GoodKey fast
BadKey fast

Key-sharing dicts and instance attributes

Instances of the same class usually have the same attribute names. PEP 412 (3.3) let their __dict__s share one table of keys and store only the values per instance. In 3.11 and 3.12 CPython goes further: an instance's attributes are stored in an inline values array laid out according to the class's shared keys, and a real __dict__ object is created only if someone asks for it. This is what makes LOAD_ATTR_INSTANCE_VALUE (Chapter 3) possible. Adding attributes in a different order in different instances, or adding many attributes outside __init__, can defeat the sharing.

set and frozenset

Theory

A set is a hash table of keys only, using the same equal-means-equal-hash rule. It does not use the compact layout: it keeps a single sparse table and probes linearly for a few slots before jumping, which is good for the cache when testing membership. Because there is no entries array, iteration follows table order, which is why sets are unordered:

print({3, 1, 2})
print({100, 33, 1, 64})
print(list({"b": 1, "a": 2}), list(dict.fromkeys([3, 1, 3, 2])))
{1, 2, 3}
{64, 33, 100, 1}
['b', 'a'] [3, 1, 2]

Small ints hash to themselves, so a set of small ints often looks sorted. That is a coincidence of the table layout, not a guarantee; strings appear in a different order on every run because of hash randomisation.

Tip

dict.fromkeys(items) is the idiomatic way to remove duplicates while keeping order, because dicts are ordered and sets are not.

Set operations cost what you would expect from hashing: x in s is O(1) on average, a | b is O(len(a) + len(b)), and a & b iterates over the smaller set and probes the larger one, so it is O(min(len(a), len(b))).

deque and the rest of collections

Theory

collections.deque is a doubly linked list of blocks, each holding 64 item pointers. Appending or popping at either end only touches the end block, so both ends are O(1). Indexing near the middle has to walk blocks from the nearer end, so d[i] is O(n) in the worst case, and there is no slicing.

from collections import deque

d = deque(range(5), maxlen=5)
d.append(5)
d.appendleft(-1)
print(d)
d.rotate(2)
print(d, d[0], d[-1])
deque([-1, 1, 2, 3, 4], maxlen=5)
deque([3, 4, -1, 1, 2], maxlen=5) 3 2

With maxlen set, adding at one end silently drops an item from the other, which makes a bounded deque a ready-made sliding window or "last N items" buffer.

Other tools worth knowing:

  • heapq: a binary min-heap stored in a plain list (heap[k] <= heap[2k+1], heap[2k+2]). heappush/heappop are O(log n); heapify is O(n).
  • bisect: binary search on a sorted list. Finding the position is O(log n), but insort is O(n) because inserting into the list shifts items.
  • Counter, defaultdict and OrderedDict are dict subclasses. OrderedDict still matters for move_to_end and order-sensitive equality.
import heapq, bisect

tasks = [(3, "write"), (1, "plan"), (2, "test")]
heapq.heapify(tasks)
print([heapq.heappop(tasks)[1] for _ in range(3)])
print(heapq.nlargest(2, [5, 1, 9, 3]))
scores = [10, 20, 30, 40]
print(bisect.bisect_left(scores, 25), bisect.bisect_right(scores, 30))
['plan', 'test', 'write']
[9, 5]
2 3

Concept and tricky questions

Drills

ConceptWhy is list.append amortised O(1) when it sometimes has to copy the whole array?

Answer

Answer. Because the capacity grows by a constant factor (about 1.125× plus a constant in CPython), the expensive copies happen at sizes that form a geometric series. Their total cost over n appends is O(n), so each append costs O(1) on average.

Why. Growing by a fixed amount (say +10 each time) would make the copies total O(n²). The factor trades memory slack for fewer copies.

ConceptDescribe CPython's dict layout since 3.6 and explain how it keeps insertion order.

Answer

Answer. A sparse index table of small integers plus a dense entries array of (hash, key, value). New entries are appended to the entries array, and the index table points into it. Iteration walks the entries array, so it follows insertion order.

Why. Ordering was a side effect of a memory optimisation (Raymond Hettinger's compact dict). It became a language guarantee in 3.7.

ConceptState the contract between __eq__ and __hash__, and what Python does to __hash__ when you define only __eq__.

Answer

Answer. Objects that compare equal must have equal hashes. If a class defines __eq__ but not __hash__, Python sets __hash__ = None, which makes instances unhashable.

Why. The default identity hash would break the contract for objects your __eq__ considers equal. If instances are mutable and equality depends on mutable fields, leaving them unhashable is correct.

TrickyWhy is hash(-1) equal to -2?

Answer

Answer. In the C API, a hash function returns -1 to signal an error. So a genuine hash of -1 is replaced by -2.

Why. It is a detail of PyObject_Hash, but it shows that hashes are allowed to collide: -1 and -2 are different keys with the same hash and still work as dict keys.

TrickyA set of small integers prints in sorted order. Is iteration order of a set guaranteed?

Answer

Answer. No. Small ints hash to themselves, so they land in slots in numeric order in a small table. That looks sorted by coincidence. Strings appear in a different order each run because their hashes are randomised.

Why. Sets have no entries array, so their order is the order of the hash table. Only dicts guarantee insertion order.

ConceptWhy do dicts and sets have a worst case of O(n) per lookup, and what protects against it in practice?

Answer

Answer. If many keys collide (same hash or same probe sequence), a lookup must walk a long probe chain comparing keys. Good hash functions, perturbation that mixes in all hash bits, a load factor of at most 2/3 and randomised str/bytes hashes (against crafted inputs) make long chains very unlikely.

Why. Before hash randomisation (3.3 by default), web servers could be attacked by POSTing form fields whose names all collided.

ConceptWhen would you use a deque instead of a list, and what does a deque do badly?

Answer

Answer. For queues and sliding windows: O(1) appends and pops at both ends. A deque is bad at random access in the middle (O(n) walking its 64-item blocks) and has no slicing or sort.

Why. A list's front operations shift every pointer. A deque never shifts; it allocates a new block at the end that needs one.

Predict the output and find the bug

Drills

OutputWhat does this print?

d = {"a": 1, "b": 2, "c": 3}
del d["a"]
d["a"] = 1
d["b"] = 20
print(list(d.items()))
Answer
[('b', 20), ('c', 3), ('a', 1)]

Answer. Deleting a removes its entry; re-inserting appends a new entry at the end. Updating an existing key keeps its position. Result: [('b', 20), ('c', 3), ('a', 1)].

Why. Order is insertion order of the key's current entry. Assigning to an existing key replaces the value in place.

OutputWhat does this print?

d = {1: "a", 2: "b"}
try:
    for k in d:
        d[k + 10] = "new"
except RuntimeError as e:
    print("RuntimeError:", e)

items = [1, 2, 3, 4]
for x in items:
    if x % 2 == 0:
        items.remove(x)
print(items)
Answer
RuntimeError: dictionary changed size during iteration
[1, 3]

Answer. Adding keys while iterating a dict raises RuntimeError: dictionary changed size during iteration. A list does not detect changes; removing 2 shifts 3 into its slot, so the loop skips it. 4 is then visited and removed, giving [1, 3]. With [2, 4, 6, 8] the same loop would wrongly keep 4 and 8.

Why. Iterate over a copy (for x in items[:]) or build a new list with a comprehension.

OutputWhat does this print?

class Point:
    def __init__(self, x):
        self.x = x
    def __eq__(self, other):
        return self.x == other.x
    def __hash__(self):
        return hash(self.x)

p = Point(1)
s = {p}
p.x = 2
print(p in s, Point(1) in s, Point(2) in s, len(s))
Answer
False False False 1

Answer. False False False 1. After mutation p hashes to the slot for 2, where nothing is stored. Point(1) finds the old slot, but the stored object no longer compares equal to it. The element is still in the set but unreachable by lookup.

Why. Never mutate the fields that __hash__ depends on while the object is in a set or used as a dict key. Make such classes immutable (@dataclass(frozen=True)).

OutputWhat does this print?

import sys
a = [0] * 10
b = [0 for _ in range(10)]
c = list(range(10))
print(sys.getsizeof(a), sys.getsizeof(b), sys.getsizeof(c))
Answer
136 184 136

Answer. [0] * 10 and list(range(10)) know the final length and allocate exactly 10 slots. The comprehension appends one item at a time and ends with over-allocated capacity, so it is larger.

Why. Pre-sizing avoids waste. It rarely matters, but it explains why two equal lists can report different getsizeof values.

OutputWhat does this print?

small = {1, 2}
big = set(range(10**6))
print(len(small & big), len(big & small), (small & big) == (big & small))
print(small.isdisjoint(big), {1, 2} <= big)
Answer
2 2 True
False True

Answer. 2 2 True, then False True. Both intersections are cheap, because CPython iterates over whichever set is smaller and probes the other.

Why. Intersection is O(min(len(a), len(b))). Subset testing iterates over the left operand, so {1, 2} <= big is O(2).

Find the bugThis BFS is correct but slow on large graphs. Find the performance bug.

from collections import deque

def bfs_slow(adj, start):
    seen, order, queue = [start], [], [start]
    while queue:
        node = queue.pop(0)
        order.append(node)
        for nxt in adj[node]:
            if nxt not in seen:
                seen.append(nxt)
                queue.append(nxt)
    return order

def bfs(adj, start):
    seen, order, queue = {start}, [], deque([start])
    while queue:
        node = queue.popleft()
        order.append(node)
        for nxt in adj[node]:
            if nxt not in seen:
                seen.add(nxt)
                queue.append(nxt)
    return order

adj = {i: [i + 1, i + 2] if i < 2000 else [] for i in range(2003)}
print(bfs_slow(adj, 0) == bfs(adj, 0))
Answer
True

Answer. Two quadratic operations: queue.pop(0) shifts the whole list, and nxt not in seen scans a list. Use a deque for the queue and a set for seen, as in bfs.

Why. Each mistake turns an O(V + E) algorithm into O(V²). This is the most common performance bug in interview solutions.

Find the bugA team used @dataclass(unsafe_hash=True) for grid cells used as dict keys, and lookups randomly failed after cells were updated. The version below is the fix. What was the bug, and why does frozen=True fix it?

from dataclasses import dataclass

@dataclass(frozen=True)
class Cell:
    row: int
    col: int

grid = {Cell(0, 0): "start"}
try:
    next(iter(grid)).row = 5
except Exception as e:
    print(type(e).__name__)
print(grid[Cell(0, 0)])
Answer
FrozenInstanceError
start

Answer. unsafe_hash=True generates a hash from the fields but leaves them mutable, so code that changed row or col on a key moved it to the wrong hash slot (the mutated-key problem from the drill above). frozen=True generates the same hash and also makes assignment raise FrozenInstanceError, so a key can never change after it is stored.

Why. A plain @dataclass (with the default eq=True) sets __hash__ to None, making instances unhashable. That is deliberately safe; unsafe_hash is named that way for a reason.

A hashable key must not change in a way that changes its hash or equality.

Write the code

Coding

CodeImplement LRUCache(capacity) with O(1) get(key) (returning -1 if missing) and put(key, value) that evicts the least recently used key when full. Use OrderedDict, then explain how you would do it without it.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return -1
        self.data.move_to_end(key)
        return self.data[key]

    def put(self, key, value):
        if key in self.data:
            self.data.move_to_end(key)
        self.data[key] = value
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)

c = LRUCache(2)
c.put(1, 1); c.put(2, 2)
print(c.get(1))
c.put(3, 3)
print(c.get(2), c.get(3), list(c.data))
1
-1 3 [1, 3]

Without OrderedDict, keep a dict from key to a node of a doubly linked list. get unlinks the node and moves it to the head; put adds at the head and removes the tail node and its dict entry when over capacity. OrderedDict is implemented exactly this way in C.

CodeWrite a tiny open-addressing hash map, ProbeMap, with __setitem__, __getitem__, __delitem__ and __len__, using CPython's probe recurrence i = (5*i + 1 + perturb) % size; perturb >>= 5, tombstones for deletes and resizing at 2/3 load.

Estimated difficulty: about 1600 (Codeforces-style)

Answer
_EMPTY, _DELETED = object(), object()

class ProbeMap:
    def __init__(self):
        self._slots = [_EMPTY] * 8
        self._used = 0      # live entries
        self._filled = 0    # live + tombstones

    def _probe(self, key):
        h = hash(key)
        mask = len(self._slots) - 1
        perturb = h & 0xFFFFFFFFFFFFFFFF
        i = h & mask
        while True:
            yield i
            perturb >>= 5
            i = (5 * i + 1 + perturb) & mask

    def _find(self, key):
        first_free = None
        for i in self._probe(key):
            slot = self._slots[i]
            if slot is _EMPTY:
                return (first_free if first_free is not None else i), False
            if slot is _DELETED:
                if first_free is None:
                    first_free = i
            elif slot[0] is key or slot[0] == key:
                return i, True

    def __setitem__(self, key, value):
        i, found = self._find(key)
        if not found:
            if self._slots[i] is _EMPTY:
                self._filled += 1
            self._used += 1
        self._slots[i] = (key, value)
        if self._filled * 3 >= len(self._slots) * 2:
            self._resize()

    def __getitem__(self, key):
        i, found = self._find(key)
        if not found:
            raise KeyError(key)
        return self._slots[i][1]

    def __delitem__(self, key):
        i, found = self._find(key)
        if not found:
            raise KeyError(key)
        self._slots[i] = _DELETED
        self._used -= 1

    def __len__(self):
        return self._used

    def _resize(self):
        old = [s for s in self._slots if s is not _EMPTY and s is not _DELETED]
        size = 8
        while size <= self._used * 3:
            size *= 2
        self._slots = [_EMPTY] * size
        self._used = self._filled = 0
        for k, v in old:
            self[k] = v

m = ProbeMap()
for i in range(100):
    m[f"k{i}"] = i
for i in range(0, 100, 2):
    del m[f"k{i}"]
print(len(m), m["k51"], len(m._slots))
try:
    m["k50"]
except KeyError as e:
    print("KeyError", e)
50 51 512
KeyError 'k50'

Tombstones keep probe chains intact: if a deleted slot were reset to empty, a lookup for a key further down the same chain would stop early and wrongly report it missing.

CodeWrite top_k_frequent(words, k) returning the k most common words, ties broken alphabetically, in O(n log k). Do not sort the whole vocabulary.

Estimated difficulty: about 1300 (Codeforces-style)

Answer

Count with a Counter, then keep a heap of size k.

import heapq
from collections import Counter

def top_k_frequent(words, k):
    counts = Counter(words)
    return heapq.nsmallest(k, counts, key=lambda w: (-counts[w], w))

text = "the cat and the hat and the bat sat on the mat and cat".split()
print(top_k_frequent(text, 3))
['the', 'and', 'cat']

heapq.nsmallest(k, …) keeps a heap of k items while scanning, so it is O(n log k). For k close to n, sorted is just as good.

CodeWrite SlidingMax(k) with push(x) that returns the maximum of the last k values pushed, in amortised O(1), using a deque of indices kept in decreasing order of value.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
from collections import deque

class SlidingMax:
    def __init__(self, k):
        self.k = k
        self.i = 0
        self.vals = {}
        self.dq = deque()        # indices, values decreasing

    def push(self, x):
        while self.dq and self.vals[self.dq[-1]] <= x:
            self.vals.pop(self.dq.pop())
        self.dq.append(self.i)
        self.vals[self.i] = x
        if self.dq[0] <= self.i - self.k:
            self.vals.pop(self.dq.popleft())
        self.i += 1
        return self.vals[self.dq[0]]

sm = SlidingMax(3)
print([sm.push(x) for x in [1, 3, -1, -3, 5, 3, 6, 7]])
[1, 3, 3, 3, 5, 5, 6, 7]

Each index enters and leaves the deque once, so the total work is O(n). A plain list would make popleft O(k).

Cheat sheet

Theory
  • list: header + separate array of pointers; growth (n + (n >> 3) + 6) & ~3 → capacities 0, 4, 8, 16, 24, 32, 40, 52, 64, 76…; append amortised O(1); front insert/pop O(n).
  • Timsort: stable, finds runs, galloping merges; O(n) on sorted input. Multi-key sort = sort by secondary, then by primary.
  • tuple: items inline, empty tuple singleton, free lists for lengths below 20; hashable only if all items are.
  • dict: compact layout (sparse index table + dense entries), insertion-ordered, load ≤ 2/3, resize to a power of two, never shrinks on delete, tombstones for deleted keys.
  • Probing: i = (5*i + 1 + perturb) mod 2^k, perturb >>= 5.
  • Hash rules: equal ⇒ same hash; hash(1) == hash(1.0) == hash(True); hash(-1) == -2; ints mod 2^61 − 1; str/bytes randomised per process.
  • Defining __eq__ without __hash__ makes instances unhashable. Never mutate a key's hashed fields.
  • set: keys-only sparse table, unordered; & iterates the smaller set. Use dict.fromkeys for ordered de-duplication.
  • deque: blocks of 64 pointers, O(1) at both ends, O(n) in the middle, maxlen for windows.
  • heapq (min-heap in a list), bisect (O(log n) search, O(n) insert), Counter, defaultdict, OrderedDict.move_to_end.

Part C · Data structures inside · Chapter 6

Strings, bytes and Unicode

A Python str is an immutable sequence of Unicode code points, and CPython stores it in one of three widths chosen by the largest character it contains. This chapter covers that flexible representation, why indexing is O(1) when the text is not stored as UTF-8, when += in a loop is fast and when it is quadratic, how bytes, bytearray and memoryview differ, and the Unicode details (normalisation, case folding, grapheme clusters) that break naive string code.

TheoryDrillsCoding· about 3 hours· prerequisites: Chapters 1 and 5

How CPython stores a str

Theory

Since 3.3 (PEP 393), CPython stores each string's characters in a fixed-width array whose width is picked from the largest code point in the string:

Largest characterStorage ("kind")Bytes per character
U+0000 – U+007FASCII (compact, smaller header)1
U+0080 – U+00FFLatin-1 (UCS-1)1
U+0100 – U+FFFFUCS-22
U+10000 – U+10FFFFUCS-44

Every character occupies the same number of bytes, so s[i] is a direct array access and len(s) is stored in the header. Both are O(1). An ASCII string has a 40-byte header; any other kind uses a 56-byte header, which has room for a cached UTF-8 copy. A terminating NUL character is always included.

import sys
samples = {
    "ascii": "a" * 100,
    "latin-1": "é" * 100,
    "ucs-2": "ā" * 100,
    "ucs-4": "😀" * 100,
    "one emoji": "a" * 99 + "😀",
}
for name, s in samples.items():
    print(f"{name:>9}: len {len(s)}, {sys.getsizeof(s)} bytes")
    ascii: len 100, 141 bytes
  latin-1: len 100, 157 bytes
    ucs-2: len 100, 258 bytes
    ucs-4: len 100, 460 bytes
one emoji: len 100, 460 bytes

The last line is the surprise: a single emoji forces the whole string to 4 bytes per character, so 99 ASCII letters plus one emoji take as much memory as 100 emoji.

Key idea

Languages that store strings as UTF-8 (Go, Rust, Swift) give O(1) access to bytes but O(n) access to the n-th character. CPython chose fixed-width storage, so indexing by character is O(1) at the cost of more memory for mixed text. Encoding to UTF-8 (s.encode()) is a separate copy, cached for non-ASCII strings when C code asks for it.

Strings are immutable

Every string method returns a new string. Immutability is what allows strings to be hashed (and the hash to be cached in the object after the first computation), interned and shared safely between threads.

s = "hello"
try:
    s[0] = "H"
except TypeError as e:
    print("TypeError:", e)
t = s.replace("h", "H")
print(s, t, s is t)
u = s.replace("z", "Z")
print(u is s)
TypeError: 'str' object does not support item assignment
hello Hello False
True

When a method has nothing to change, CPython often returns the original object (replace with no match returns s itself). That is an optimisation you can see with is, not a guarantee.

Building strings: += versus join

Theory

Because strings are immutable, s = s + t must allocate a new string and copy both parts. Doing that n times in a loop copies 1 + 2 + … + n characters, which is O(n²).

CPython has an optimisation for the common case: when the left operand of += is a local variable and this is the only reference to the string, it resizes the string in place with realloc, which is usually amortised O(1). The moment any other reference exists, the optimisation is off and the loop becomes quadratic:

import timeit

def build_plus(n):
    s = ""
    for _ in range(n):
        s += "x"
    return s

def build_plus_shared(n):
    s = ""
    snapshots = []
    for _ in range(n):
        snapshots.append(s)    # a second reference to s
        s += "x"
    return s

def build_join(n):
    return "".join("x" for _ in range(n))

n = 20_000
t_plus = timeit.timeit(lambda: build_plus(n), number=3)
t_shared = timeit.timeit(lambda: build_plus_shared(n), number=3)
t_join = timeit.timeit(lambda: build_join(n), number=3)
print("shared += is 10x slower:", t_shared > 10 * t_plus)
print("join within 3x of fast +=:", t_join < 3 * t_plus)
shared += is 10x slower: True
join within 3x of fast +=: True

str.join computes the total length first, allocates once and copies each piece once, so it is O(total length) on every implementation. That is why the standard advice is to collect pieces in a list and join at the end, or to use io.StringIO.

Interview trap

Do not explain the += speed-up as "Python strings are mutable". They are not. The interpreter reuses the memory only when no one else can observe the old value, and PyPy and other implementations do not do it at all.

Formatting

f-strings compile to dedicated bytecode (FORMAT_VALUE and BUILD_STRING), so they are usually the fastest way to format. Since 3.12 (PEP 701) they are parsed by the real grammar, so you can reuse the same quote character and write comments and backslashes inside the braces:

user = {"name": "Ada", "score": 0.91234}
print(f"{user["name"]} scored {user["score"]:.1%}")
width = 8
print(f"[{'left':<{width}}] [{'right':>{width}}] [{42:08b}]")
x = 3
print(f"{x = }, {x * 2 = }")
Ada scored 91.2%
[left    ] [   right] [00101010]
x = 3, x * 2 = 6

The = specifier (3.8+) prints the expression text and its value, which is handy for debugging.

bytes, bytearray and memoryview

Theory

Text and binary data are separate types in Python 3:

  • str is text: code points. It has no byte length until you encode it.
  • bytes is an immutable sequence of integers 0–255. b[0] is an int, not a one-byte bytes.
  • bytearray is the mutable version, with the same over-allocation strategy as list, so appending is amortised O(1).
  • memoryview is a window onto any object that supports the buffer protocol (bytes, bytearray, array.array, NumPy arrays). Slicing a memoryview does not copy.
data = "naïve café".encode("utf-8")
print(len("naïve café"), len(data), data[:6], data[2])
print(data.decode("utf-8"), data.decode("latin-1"))

buf = bytearray(b"hello world")
buf[0] = ord("H")
buf.extend(b"!")
print(buf, buf.upper())

view = memoryview(buf)[6:11]
view[:] = b"WORLD"
print(buf, view.nbytes, view.obj is buf)
10 12 b'na\xc3\xafve' 195
naïve café naïve café
bytearray(b'Hello world!') bytearray(b'HELLO WORLD!')
bytearray(b'Hello WORLD!') 5 True

Decoding UTF-8 bytes as Latin-1 never fails (every byte is a valid Latin-1 character) but produces "mojibake" such as naïve. Writing through the memoryview changed the underlying bytearray in place, with no copy.

Why memoryview matters

Slicing bytes or bytearray creates a copy. A parser that slices a large buffer repeatedly therefore copies over and over. A memoryview slice is O(1) and shares memory:

import timeit

payload = bytes(10_000_000)
t_copy = timeit.timeit(lambda: payload[1:], number=20)
mv = memoryview(payload)
t_view = timeit.timeit(lambda: mv[1:], number=20)
print("view slicing 100x faster:", t_view * 100 < t_copy)
print(mv[1:].nbytes, mv.readonly)
view slicing 100x faster: True
9999999 True

Sockets (sock.recv_into), files (readinto) and struct.unpack_from all accept buffers, so a network server can fill one pre-allocated bytearray and parse it through memoryviews without ever copying.

Encodings

UTF-8 encodes each code point in 1 to 4 bytes: ASCII stays 1 byte, most European and Middle Eastern letters take 2, most other BMP characters 3, and emoji 4. It is the default for encode() and decode(). open() without encoding= still uses the locale's encoding in 3.12 (PEP 686 makes UTF-8 the default from 3.15), so always pass encoding="utf-8" for files that must work on every machine.

for ch in ["A", "é", "€", "😀"]:
    print(ch, hex(ord(ch)), len(ch.encode("utf-8")), ch.encode("utf-8"))
print("€".encode("utf-16-le"), len("😀".encode("utf-16-le")))
try:
    "€".encode("ascii")
except UnicodeEncodeError as e:
    print("UnicodeEncodeError:", e.reason)
print("€".encode("ascii", errors="replace"), "€".encode("ascii", errors="xmlcharrefreplace"))
print(b"\xff\xfe".decode("utf-8", errors="replace"))
A 0x41 1 b'A'
é 0xe9 2 b'\xc3\xa9'
€ 0x20ac 3 b'\xe2\x82\xac'
😀 0x1f600 4 b'\xf0\x9f\x98\x80'
b'\xac ' 4
UnicodeEncodeError: ordinal not in range(128)
b'?' b'&#8364;'
��

Unicode traps

Theory

One visible character is not one code point

What a user sees as one character (a grapheme cluster) can be several code points: a base letter plus combining accents, an emoji with a skin-tone modifier, or a family emoji joined with zero-width joiners. len() counts code points:

family = "👨‍👩‍👧"
print(family, len(family), len(family.encode()))
print([f"U+{ord(c):04X}" for c in family])
print(family[::-1] == family, len(family[:2]))
👨‍👩‍👧 5 18
['U+1F468', 'U+200D', 'U+1F469', 'U+200D', 'U+1F467']
False 2

Reversing or slicing such a string by code points breaks the cluster apart. Correct grapheme handling needs a library such as regex (with \X) or grapheme.

The same text, two encodings in code points

é can be the single code point U+00E9 or e followed by the combining acute accent U+0301. They look identical, but they are not equal until you normalise them:

import unicodedata

composed = "café"
decomposed = "café"
print(composed == decomposed, len(composed), len(decomposed))
nfc = unicodedata.normalize("NFC", decomposed)
print(nfc == composed, len(nfc))
print(unicodedata.name("́"))
False 4 5
True 4
COMBINING ACUTE ACCENT

Normalise user input (usually to NFC) before comparing, hashing or using it as a dict key. macOS file names, for example, have historically been stored decomposed.

Case and digits

lower() is not the right tool for case-insensitive comparison. casefold() applies the full Unicode folding rules, and some mappings change the length of the string:

print("Straße".lower(), "Straße".casefold(), "Straße".upper())
print("STRASSE".casefold() == "Straße".casefold())
print(len("İ".lower()), "fi".upper())
print("²".isdigit(), "²".isdecimal(), "٣".isdecimal(), int("٣") + 1)
straße strasse STRASSE
True
2 FI
True False True 4

isdigit() accepts superscripts that int() cannot parse; isdecimal() is the check that matches what int() accepts, and int() happily parses Arabic-Indic digits. Validating input with isdigit() and then calling int() is a classic crash.

Concept and tricky questions

Drills

ConceptHow does CPython store a str, and why is s[i] O(1)?

Answer

Answer. As a fixed-width array of 1, 2 or 4 bytes per code point, chosen by the largest code point in the string (PEP 393). Because every character has the same width, the i-th character is at offset i * width.

Why. A UTF-8 representation would need a scan to find the i-th code point. The trade-off is memory: one wide character widens the entire string.

TrickyWhy can s += t in a loop be fast in CPython, and when does it become quadratic?

Answer

Answer. When s is a local whose string has no other references, CPython resizes it in place instead of copying. If any other reference exists (it is stored in a list, it is an attribute, a global, or another name points at it), each += must copy, and the loop is O(n²).

Why. The optimisation checks the refcount at run time. It is CPython-specific, so "".join(parts) remains the portable linear approach.

ConceptWhat is the difference between bytes, bytearray and memoryview?

Answer

Answer. bytes is immutable binary data, bytearray is mutable (and over-allocates for cheap appends), and memoryview is a zero-copy view onto another object's buffer that supports slicing and, for writable buffers, assignment.

Why. Interviews about high-throughput parsing want you to say "slice a memoryview instead of the bytes, and read into a pre-allocated bytearray".

Trickylen("é") returns 1 on one machine and 2 on another for text that looks identical. Why?

Answer

Answer. One string uses the precomposed code point U+00E9; the other is e plus the combining accent U+0301. Normalise with unicodedata.normalize("NFC", s) before comparing or measuring.

Why. Unicode allows several code point sequences for the same visible text. Input from different systems (keyboards, macOS file names, copy-paste) mixes them.

ConceptWhy should you use casefold() rather than lower() for case-insensitive matching?

Answer

Answer. casefold() applies Unicode's full case folding, which handles characters such as German ß (folded to ss) that lower() leaves alone. So "STRASSE".casefold() == "straße".casefold() is True, while the lower() versions differ.

Why. Case conversion is locale- and language-dependent and can change string length. For identifiers like usernames, normalise (NFKC) and then casefold.

Tricky"²".isdigit() is True. Is it safe to call int(x) after checking x.isdigit()?

Answer

Answer. No. isdigit() accepts characters such as superscripts that int() rejects. Check with isdecimal(), or better, just call int() and catch ValueError.

Why. Unicode has three numeric categories; isdecimal() (Nd) matches what int() accepts, including non-Latin digits.

Predict the output and find the bug

Drills

OutputWhat does this print?

import sys
a = "x" * 1000
b = a + "€"
c = a + "é"
print(sys.getsizeof(a), sys.getsizeof(b), sys.getsizeof(c))
Answer
1041 2060 1058

Answer. a is ASCII: 40 + 1000 + 1 bytes. Adding € (U+20AC) forces UCS-2: 56 + 2 × 1002 bytes. Adding é (U+00E9) needs only Latin-1: 56 + 1002 bytes.

Why. The width is decided by the largest code point, so the same 1000 ASCII letters cost twice as much once a euro sign joins them.

OutputWhat does this print?

b = b"abc"
print(b[0], b[0:1], list(b), bytes([97, 98]))
print(b"a" in b, 97 in b, b.hex())
Answer
97 b'a' [97, 98, 99] b'ab'
True True 616263

Answer. Indexing bytes gives an int (97), while slicing gives bytes (b'a'). Membership accepts both a sub-sequence and an int. The hex is '616263'.

Why. This is the main difference from Python 2, where b[0] was a one-character string. Code ported from Python 2 often compares b[0] == "a", which is now always False.

OutputWhat does this print?

print("a,b,,c".split(","), "  a  b ".split(), " a b ".split(" "))
print("".split(","), "".split())
print("a-b-c".rsplit("-", 1), "a\nb\r\nc".splitlines())
Answer
['a', 'b', '', 'c'] ['a', 'b'] ['', 'a', 'b', '']
[''] []
['a-b', 'c'] ['a', 'b', 'c']

Answer. split(",") keeps empty fields; split() with no argument splits on runs of whitespace and drops empties; split(" ") keeps them. Splitting an empty string with a separator gives [''], without one gives [].

Why. The no-argument form is a different algorithm, not "split on space". The ''.split(',') case often causes an off-by-one when parsing empty CSV lines.

OutputWhat does this print?

raw = "Grüße".encode("utf-8")
print(len(raw))
print(raw.decode("latin-1"))
print(raw.decode("latin-1").encode("latin-1").decode("utf-8"))
Answer
7
Grüße
Grüße

Answer. 7 bytes (ü and ß take 2 each). Decoding as Latin-1 produces mojibake, GrüÃ\x9fe, printed with the control character. Re-encoding the mojibake as Latin-1 gets the original bytes back, which then decode correctly.

Why. Latin-1 maps every byte to one code point, so it never fails and round-trips any bytes, which is also how you repair text that was decoded with the wrong codec.

Find the bugThis report builder is fine in tests and takes minutes on real data. Find the problem.

import timeit

class Report:
    def __init__(self):
        self.text = ""
    def add(self, line):
        self.text += line + "\n"

class FastReport:
    def __init__(self):
        self.parts = []
    def add(self, line):
        self.parts.append(line)
    @property
    def text(self):
        return "\n".join(self.parts) + "\n"

def fill(cls, n):
    r = cls()
    for i in range(n):
        r.add("line %d" % i)
    return r.text

print(fill(Report, 3) == fill(FastReport, 3))
slow = timeit.timeit(lambda: fill(Report, 20_000), number=1)
fast = timeit.timeit(lambda: fill(FastReport, 20_000), number=1)
print("list + join much faster:", fast * 5 < slow)
Answer
True
list + join much faster: True

Answer. self.text += … concatenates onto an attribute, so the in-place resize optimisation never applies (the instance dict holds a reference). Each append copies the whole report, which is O(n²). Collect parts in a list and join once.

Why. The fast path only exists for local variables with a single reference.

Find the bugA username check lets two accounts that look identical register. Fix the comparison.

import unicodedata

existing = {"josé"}

def canonical(name):
    return unicodedata.normalize("NFKC", name).casefold()

new = "JOSÉ"
print(new.lower() in existing)
print(canonical(new) in {canonical(n) for n in existing})
Answer
False
True

Answer. new.lower() is jose plus a combining accent, which is not equal to the precomposed josé. Normalise (NFKC also folds compatibility forms such as full-width letters) and casefold both sides before comparing, as canonical does.

Why. Store the canonical form as the unique key. This is the approach recommended for identifiers in Unicode TR #36 and used by many sign-up systems.

Write the code

Coding

CodeWrite utf8_char_count(data: bytes) that counts code points in UTF-8 bytes without decoding, by counting bytes that are not continuation bytes (0b10xxxxxx). Check it against len(data.decode()).

Estimated difficulty: about 1200 (Codeforces-style)

Answer
def utf8_char_count(data):
    return sum(1 for b in data if b & 0xC0 != 0x80)

samples = ["hello", "naïve café", "€100", "😀👍", "日本語"]
for s in samples:
    raw = s.encode()
    print(s, len(raw), utf8_char_count(raw), utf8_char_count(raw) == len(s))
hello 5 5 True
naïve café 12 10 True
€100 6 4 True
😀👍 8 2 True
日本語 9 3 True

Every UTF-8 sequence has exactly one lead byte (0xxxxxxx, 110xxxxx, 1110xxxx or 11110xxx) followed by continuation bytes, so counting lead bytes counts code points. This is how len() on a UTF-8 string would have to work.

CodeWrite a zero-copy line splitter: iter_lines(buf) takes a bytes or bytearray and yields memoryview slices for each line (without the newline), never copying the data. Show that the slices share memory with the buffer.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
def iter_lines(buf):
    view = memoryview(buf)
    start = 0
    while True:
        end = buf.find(b"\n", start)
        if end == -1:
            if start < len(buf):
                yield view[start:]
            return
        yield view[start:end]
        start = end + 1

data = bytearray(b"GET / HTTP/1.1\nHost: example.com\nAccept: */*")
lines = list(iter_lines(data))
print([bytes(l) for l in lines])
data[0:3] = b"PUT"
print(bytes(lines[0]), lines[0].obj is data)
[b'GET / HTTP/1.1', b'Host: example.com', b'Accept: */*']
b'PUT / HTTP/1.1' True

Changing the buffer changes what the first view shows, which proves it is not a copy. Note that while views exist, a bytearray cannot be resized (it raises BufferError).

CodeWrite truncate_utf8(s, max_bytes) that returns the longest prefix of s whose UTF-8 encoding fits in max_bytes, without splitting a multi-byte character (a common requirement for database columns and protocol headers).

Estimated difficulty: about 1300 (Codeforces-style)

Answer

Encode once, cut at the byte limit, and step back over continuation bytes.

def truncate_utf8(s, max_bytes):
    raw = s.encode("utf-8")
    if len(raw) <= max_bytes:
        return s
    cut = max_bytes
    while cut > 0 and raw[cut] & 0xC0 == 0x80:
        cut -= 1
    return raw[:cut].decode("utf-8")

text = "Grüße 😀!"
for n in range(3, 12, 2):
    out = truncate_utf8(text, n)
    print(n, repr(out), len(out.encode()))
3 'Gr' 2
5 'Grü' 4
7 'Grüße' 7
9 'Grüße ' 8
11 'Grüße ' 8

raw[cut] is the first byte that does not fit. If it is a continuation byte, the character it belongs to started earlier and must be dropped entirely.

CodeImplement StringBuilder with append(s), __len__ and build(), storing pieces in a list but merging them periodically so that memory stays bounded when millions of tiny pieces are appended. Compare its result with "".join.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
class StringBuilder:
    def __init__(self, chunk=4096):
        self._parts = []
        self._pending = []
        self._pending_len = 0
        self._len = 0
        self._chunk = chunk

    def append(self, s):
        self._pending.append(s)
        self._pending_len += len(s)
        self._len += len(s)
        if self._pending_len >= self._chunk:
            self._parts.append("".join(self._pending))
            self._pending.clear()
            self._pending_len = 0

    def __len__(self):
        return self._len

    def build(self):
        return "".join(self._parts) + "".join(self._pending)

sb = StringBuilder()
for i in range(100_000):
    sb.append(str(i % 10))
result = sb.build()
print(len(sb), result[:12], result == "".join(str(i % 10) for i in range(100_000)), len(sb._parts))
100000 012345678901 True 24

A list of a million one-character strings holds a million pointers (8 MB) even though the one-character strings themselves are cached singletons. Merging pieces into 4 KB chunks keeps the list short, and the final join still copies each character a bounded number of times.

Cheat sheet

Theory
  • str = immutable code points. PEP 393 width per string: ASCII/Latin-1 1 byte, UCS-2 2, UCS-4 4. ASCII header 40 bytes, others 56. One emoji widens the whole string.
  • len(s) and s[i] are O(1). UTF-8 is a separate encoding (s.encode()).
  • Methods return new strings; unchanged results may return the same object.
  • s += t in a loop is fast only for an unshared local in CPython; use "".join(parts) or io.StringIO.
  • f-strings are the fastest formatting; 3.12 allows nested same quotes. f"{x = }" for debugging.
  • bytes immutable (b[0] is an int), bytearray mutable and over-allocated, memoryview zero-copy slices over the buffer protocol.
  • UTF-8: 1–4 bytes per code point; continuation bytes are 10xxxxxx. Latin-1 decodes any bytes. Pass encoding="utf-8" to open().
  • Graphemes ≠ code points (combining marks, ZWJ emoji). Normalise (NFC/NFKC) before comparing; use casefold() for caseless matching.
  • isdecimal() matches int(); isdigit() accepts superscripts that int() rejects.

Part D · Classes and attributes · Chapter 7

The data model and special methods

Python's syntax is a thin layer over special ("dunder") methods: a + b, len(x), x[i], if x: and with all become calls to methods on the object's type. This chapter covers how those calls are dispatched (on the type, not the instance), how binary operators choose between __add__ and __radd__, what returning NotImplemented does, how truthiness, containment and comparison fall back, and what __slots__ changes in an object's memory layout.

TheoryDrillsCoding· about 3.5 hours· prerequisites: Chapters 1, 3 and 5

Syntax is method calls on the type

Theory

Every operator and many built-in functions translate into a special method:

You writePython calls
len(x)type(x).__len__(x)
x[k], x[k] = v, del x[k]__getitem__, __setitem__, __delitem__
a + btype(a).__add__(a, b), then maybe type(b).__radd__(b, a)
if x:__bool__, else __len__
k in x__contains__, else iteration
for v in x__iter__, else __getitem__ with 0, 1, 2…
x()type(x).__call__(x)
with x:__enter__ and __exit__
repr(x), str(x), f"{x}"__repr__, __str__, __format__

The important detail is in the first column of every row: CPython looks special methods up on the type, skipping the instance's own __dict__. At the C level each type has slots (tp_as_number->nb_add, tp_as_sequence->sq_length …) that are filled from the class's dunder methods when the class is created or modified, and the interpreter calls the slot directly.

class Box:
    def __len__(self):
        return 3

b = Box()
b.__len__ = lambda: 99          # an instance attribute
print(len(b), b.__len__())

Box.__len__ = lambda self: 7    # changing the class updates the slot
print(len(b))
3 99
7

len(b) ignores the instance attribute, while the explicit b.__len__() is an ordinary attribute lookup and finds it. Assigning to the class works because type.__setattr__ notices dunder names and refreshes the C slot.

Key idea

Because special methods are found on the type, you cannot give one instance its own operator behaviour by assigning a method to it, and __getattr__ on the instance's class is never consulted for implicit special method calls.

class Proxy:
    def __init__(self, target):
        self._target = target
    def __getattr__(self, name):
        return getattr(self._target, name)

p = Proxy([1, 2, 3])
print(p.count(2), p.__len__())
try:
    len(p)
except TypeError as e:
    print("TypeError:", e)
1 3
TypeError: object of type 'Proxy' has no len()

A transparent proxy has to define every special method it wants to forward explicitly, which is why libraries such as wrapt exist.

Binary operators and NotImplemented

Theory

For a + b, Python follows these rules:

  1. If type(b) is a subclass of type(a) and overrides __radd__, try b.__radd__(a) first. This lets a subclass control mixed operations with its base.
  2. Otherwise try a.__add__(b).
  3. If that method is missing or returns the special singleton NotImplemented, try b.__radd__(a) (only if the types differ).
  4. If both give NotImplemented, raise TypeError.

Returning NotImplemented (not raising NotImplementedError) is how a method says "I do not know how to handle this type, ask the other operand".

class Money:
    def __init__(self, cents):
        self.cents = cents
    def __add__(self, other):
        if isinstance(other, Money):
            return Money(self.cents + other.cents)
        if isinstance(other, int):
            return Money(self.cents + other)
        return NotImplemented
    def __radd__(self, other):
        return self.__add__(other)
    def __repr__(self):
        return f"Money({self.cents})"

print(Money(5) + Money(7), Money(5) + 3, 3 + Money(5))
print(sum([Money(1), Money(2)]))
try:
    Money(1) + "x"
except TypeError as e:
    print("TypeError:", e)
Money(12) Money(8) Money(8)
Money(3)
TypeError: unsupported operand type(s) for +: 'Money' and 'str'

3 + Money(5) works because int.__add__ returns NotImplemented for a Money, so Python calls Money.__radd__. sum starts from 0, so it also relies on __radd__.

Augmented assignment

a += b tries a.__iadd__(b) first. If it is missing or returns NotImplemented, Python falls back to a = a + b (with the full __add__/__radd__ dance). Mutable types implement __iadd__ to modify themselves and return self; immutable types do not define it, so += creates a new object (Chapter 1).

The subclass rule

class Base:
    def __add__(self, other):
        return "Base.__add__"
    def __radd__(self, other):
        return "Base.__radd__"

class Derived(Base):
    def __radd__(self, other):
        return "Derived.__radd__"

print(Base() + Derived())
print(Derived() + Base())
Derived.__radd__
Base.__add__

Even though Base is on the left, Derived.__radd__ wins, because the right operand's type is a subclass that overrides the reflected method.

Comparisons, truthiness and containment

Theory

Rich comparisons

==, !=, <, <=, >, >= call __eq__, __ne__, __lt__, __le__, __gt__, __ge__. Their reflections are swapped: if a < b gets NotImplemented, Python tries b > a. For ==, the final fallback when both sides return NotImplemented is an identity comparison, so == never raises TypeError. __ne__ by default inverts __eq__.

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, text):
        self.parts = tuple(int(p) for p in text.split("."))
    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)
    def __repr__(self):
        return ".".join(map(str, self.parts))

vs = [Version("1.10"), Version("1.2"), Version("1.9.1")]
print(sorted(vs), max(vs), Version("1.2") >= Version("1.2.0"))
print(Version("1.0") == "1.0", Version("1.0") != Version("1.0"))
[1.2, 1.9.1, 1.10] 1.10 False
False False

total_ordering derives <=, > and >= from __eq__ and __lt__. Comparing tuples of ints gives the natural version order (1.2 < 1.9.1 < 1.10), which string comparison gets wrong. Note the False for 1.2 >= 1.2.0: the tuples (1, 2) and (1, 2, 0) differ, and a shorter tuple that is a prefix of a longer one sorts first. A real version class would strip or pad trailing zeros before comparing.

Truthiness

if x: calls type(x).__bool__. If the class has no __bool__, Python uses __len__ and treats zero as false. If it has neither, the object is always true.

class Empty:
    def __len__(self):
        return 0

class Plain:
    pass

class Strict:
    def __bool__(self):
        return 1

print(bool(Empty()), bool(Plain()), bool([]), bool([0]), bool(0.0), bool("0"))
try:
    bool(Strict())
except TypeError as e:
    print("TypeError:", e)
False True False True False True
TypeError: __bool__ should return bool, returned int

__bool__ must return a real bool; returning 1 raises TypeError.

Containment and iteration fallbacks

x in obj uses __contains__ if defined. Otherwise Python iterates over obj with __iter__, and if there is no __iter__ either, it uses the old sequence protocol: call __getitem__(0), __getitem__(1) … until IndexError. So defining only __getitem__ is enough to make an object iterable and searchable:

class Squares:
    def __getitem__(self, i):
        if i >= 5:
            raise IndexError(i)
        return i * i

s = Squares()
print(list(s), 9 in s, 10 in s)
print(iter(s))
[0, 1, 4, 9, 16] True False
<iterator object at 0x…>

Representations

Theory
  • __repr__ should be unambiguous, for developers. Containers use the repr of their items, and the REPL and debuggers show it. If possible it looks like an expression that rebuilds the object.
  • __str__ is the readable form used by print and str(). It defaults to __repr__.
  • __format__(spec) handles format specs in f-strings and format().
from datetime import date

class Temp:
    def __init__(self, c):
        self.c = c
    def __repr__(self):
        return f"Temp({self.c!r})"
    def __str__(self):
        return f"{self.c}°C"
    def __format__(self, spec):
        if spec == "f":
            return f"{self.c * 9 / 5 + 32:.1f}°F"
        return format(str(self), spec)

t = Temp(21.5)
print(t, repr(t), [t])
print(f"{t:f} | {t:>8} | {date(2026, 1, 2):%d %b}")
21.5°C Temp(21.5) [Temp(21.5)]
70.7°F |   21.5°C | 02 Jan

__slots__

Theory

By default each instance has a __dict__ for its attributes (stored as inline values with shared keys in 3.11+, Chapter 5, but still with room for a dict). Declaring __slots__ replaces the per-instance dict with fixed fields at known offsets in the object, accessed through member descriptors on the class:

import sys, tracemalloc

class Plain:
    def __init__(self, x, y):
        self.x, self.y = x, y

class Slotted:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

def measure(cls, n=10_000):
    tracemalloc.start()
    objs = [cls(i, i) for i in range(n)]
    size = tracemalloc.get_traced_memory()[0]
    tracemalloc.stop()
    return size // n

print("bytes per object:", measure(Plain), measure(Slotted))
s = Slotted(1, 2)
print(type(Slotted.__dict__["x"]).__name__, hasattr(s, "__dict__"))
try:
    s.z = 3
except AttributeError as e:
    print("AttributeError:", e)
bytes per object: 120 87
member_descriptor False
AttributeError: 'Slotted' object has no attribute 'z'

The rules that trip people up:

  • Slots save memory per instance and make attribute access slightly faster, but you can no longer add arbitrary attributes.
  • A subclass without its own __slots__ gets a __dict__ again, so every class in the hierarchy must declare slots to keep the saving.
  • Slotted instances cannot be weakly referenced unless "__weakref__" is in the slots.
  • A slot name cannot also be a class attribute (the descriptor would be overwritten), so defaults go in __init__. @dataclass(slots=True) (3.10+) handles this for you.

Concept and tricky questions

Drills

ConceptWhy does len(obj) ignore a __len__ attribute set on the instance?

Answer

Answer. Implicit special method calls look the method up on the type (type(obj).__len__), not on the instance. CPython calls the C slot of the type directly.

Why. It is faster, and it avoids a metaclass confusion: len(SomeClass) must use type(SomeClass).__len__, not the __len__ that SomeClass defines for its instances.

ConceptWhat is the difference between returning NotImplemented and raising NotImplementedError?

Answer

Answer. NotImplemented is a singleton returned by binary special methods to say "I cannot handle this operand type"; Python then tries the reflected method of the other operand. NotImplementedError is an exception for abstract methods that a subclass must override.

Why. Raising NotImplementedError from __add__ stops the dispatch and the other operand never gets a chance. Returning NotImplementedError (the class) would be truthy and treated as a real result.

ConceptDescribe the steps Python takes to evaluate a + b.

Answer

Answer. If type(b) is a proper subclass of type(a) and provides a different __radd__, call b.__radd__(a) first. Otherwise call a.__add__(b); if it is missing or returns NotImplemented, call b.__radd__(a); if that also fails, raise TypeError.

Why. The subclass rule lets a more specific type take control of mixed arithmetic, which NumPy and fractions.Fraction rely on.

TrickyWhat does bool(x) do for an object whose class defines __len__ but not __bool__, and for one that defines neither?

Answer

Answer. With only __len__, it is false when the length is 0. With neither, it is always true.

Why. That is why if items: is the idiomatic emptiness check for any container, and why a class whose instances are always truthy can surprise you when you expected if result: to test for "no data".

TrickyA class defines only __getitem__. Can you iterate over it and use in with it?

Answer

Answer. Yes. Without __iter__, iter() falls back to the sequence protocol, calling __getitem__ with 0, 1, 2… until IndexError. in without __contains__ iterates the same way.

Why. It is a legacy protocol kept for compatibility, and it can surprise you with a mapping-like class, where __getitem__(0) raises KeyError, not IndexError, so iteration fails with a KeyError.

ConceptWhat does __slots__ change about an instance, and name two things you lose.

Answer

Answer. The instance stores the listed attributes at fixed offsets instead of in a __dict__, which saves memory and speeds up access. You lose the ability to add other attributes, weak-reference support (unless __weakref__ is listed), and class-level default values for the slot names.

Why. The class gets a member descriptor per slot that reads and writes the fixed offset. Subclasses must also declare __slots__, or they get a __dict__ back.

TrickyWhy does x == y never raise TypeError for unrelated types, while x < y does?

Answer

Answer. If both __eq__ calls return NotImplemented, Python falls back to identity (x is y). Ordering comparisons have no such fallback, so they raise TypeError: '<' not supported.

Why. Equality between unrelated objects has an obvious answer (not equal) but ordering does not. Python 2 used to order arbitrary objects by type name, which hid bugs.

Predict the output and find the bug

Drills

OutputWhat does this print?

class V:
    def __init__(self, n):
        self.n = n
    def __add__(self, other):
        return V(self.n + getattr(other, "n", other))
    def __repr__(self):
        return f"V({self.n})"

print(V(1) + V(2), V(1) + 5)
try:
    print(5 + V(1))
except TypeError as e:
    print("TypeError:", e)
Answer
V(3) V(6)
TypeError: unsupported operand type(s) for +: 'int' and 'V'

Answer. V(3) V(6), then a TypeError for 5 + V(1): int.__add__ returns NotImplemented and V has no __radd__.

Why. Defining __add__ alone only covers the case where your object is on the left. Add __radd__ = __add__ when the operation is commutative.

OutputWhat does this print?

class A:
    def __eq__(self, other):
        print("A.__eq__")
        return NotImplemented

class B:
    def __eq__(self, other):
        print("B.__eq__")
        return NotImplemented

print(A() == B())
print(A() != B())
Answer
A.__eq__
B.__eq__
False
A.__eq__
B.__eq__
True

Answer. For ==: A.__eq__, then the reflected B.__eq__, then False from the identity fallback. For !=: there is no __ne__, so the default __ne__ calls __eq__ and inverts it, printing A.__eq__ and B.__eq__ again, then True.

Why. object.__ne__ delegates to __eq__ and inverts any result that is not NotImplemented; when both sides decline, != also falls back to identity.

OutputWhat does this print?

class Acc:
    def __init__(self):
        self.items = []
    def __add__(self, x):
        new = Acc()
        new.items = self.items + [x]
        return new
    def __iadd__(self, x):
        self.items.append(x)
        return self

a = Acc()
b = a
a += 1
c = a + 2
print(a is b, a.items, c.items, c is a)
Answer
True [1] [1, 2] False

Answer. __iadd__ mutates and returns self, so a is still b with [1]. a + 2 builds a new object with [1, 2]. Output: True [1] [1, 2] False.

Why. If __iadd__ forgot to return self, a would be rebound to None, which is a very common bug.

OutputWhat does this print?

class P:
    __slots__ = ("x",)

class C(P):
    pass

class D(P):
    __slots__ = ("y",)

c, d = C(), D()
c.anything = 1
print(hasattr(c, "__dict__"), hasattr(d, "__dict__"))
try:
    d.anything = 1
except AttributeError as e:
    print("AttributeError:", e)
Answer
True False
AttributeError: 'D' object has no attribute 'anything'

Answer. C declares no slots, so its instances get a __dict__ and accept any attribute. D declares slots, so its instances do not have a __dict__ and reject anything.

Why. Each class in the chain decides for itself whether to add a __dict__. A single slot-less subclass undoes the memory saving.

OutputWhat does this print?

class Result:
    def __init__(self, rows):
        self.rows = rows
    def __len__(self):
        return len(self.rows)

r = Result([])
print("found" if r else "empty", r is not None, bool(Result([0])))
Answer
empty True True

Answer. empty True True. With __len__ defined, an empty result is falsy even though the object exists; a result holding one row (even the falsy 0) is truthy.

Why. This is why code that means "did the query return an object?" should test is not None, not truthiness.

Find the bugThis __eq__ makes comparisons with other types crash. Fix it.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)
    __hash__ = None

class BrokenPoint(Point):
    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)

print(Point(1, 2) == Point(1, 2), Point(1, 2) == "p", Point(1, 2) in [None, Point(1, 2)])
try:
    BrokenPoint(1, 2) == None
except AttributeError as e:
    print("AttributeError:", e)
Answer
True False True
AttributeError: 'NoneType' object has no attribute 'x'

Answer. BrokenPoint.__eq__ assumes other has x and y, so comparing with None or a string raises AttributeError, and so does x in list_with_none. Check the type and return NotImplemented, as Point.__eq__ does.

Why. __eq__ is called implicitly by in, list.index, dict lookups and assertEqual, often with objects of other types. It should never raise for an unexpected type.

Find the bugA vector class supports v * 3 but 3 * v crashes, and sum(vectors) fails. Find the missing methods.

class Vec:
    def __init__(self, *xs):
        self.xs = xs
    def __add__(self, other):
        if isinstance(other, Vec):
            return Vec(*(a + b for a, b in zip(self.xs, other.xs)))
        if other == 0:
            return self
        return NotImplemented
    __radd__ = __add__
    def __mul__(self, k):
        if isinstance(k, (int, float)):
            return Vec(*(a * k for a in self.xs))
        return NotImplemented
    __rmul__ = __mul__
    def __repr__(self):
        return f"Vec{self.xs}"

v = Vec(1, 2)
print(v * 3, 3 * v, sum([v, v, v]))
Answer
Vec(3, 6) Vec(3, 6) Vec(3, 6)

Answer. It was missing __rmul__ (for 3 * v) and __radd__ plus handling of 0 (for sum, which starts with 0 + v). The fixed version above aliases both reflected methods and treats adding 0 as the identity.

Why. Any class used with built-ins such as sum must cope with the start value. Alternatively, call sum(vectors, Vec(0, 0)).

Write the code

Coding

CodeWrite a Matrix class with __getitem__ taking (row, col) tuples, __matmul__ for @, __eq__, __repr__ and __iter__ over rows. Show a @ b and a[1, 0].

Estimated difficulty: about 1300 (Codeforces-style)

Answer
class Matrix:
    def __init__(self, rows):
        self.rows = [list(r) for r in rows]
        self.n, self.m = len(self.rows), len(self.rows[0])

    def __getitem__(self, idx):
        r, c = idx
        return self.rows[r][c]

    def __matmul__(self, other):
        if not isinstance(other, Matrix) or self.m != other.n:
            return NotImplemented
        cols = list(zip(*other.rows))
        return Matrix([[sum(a * b for a, b in zip(row, col)) for col in cols] for row in self.rows])

    def __eq__(self, other):
        return isinstance(other, Matrix) and self.rows == other.rows

    __hash__ = None

    def __iter__(self):
        return iter(self.rows)

    def __repr__(self):
        return f"Matrix({self.rows})"

a = Matrix([[1, 2], [3, 4]])
b = Matrix([[0, 1], [1, 0]])
print(a @ b, a[1, 0], list(a)[0], a @ b == Matrix([[2, 1], [4, 3]]))
Matrix([[2, 1], [4, 3]]) 3 [1, 2] True

a[1, 0] passes the tuple (1, 0) as a single key. @ (PEP 465) maps to __matmul__, with __rmatmul__ and __imatmul__ as its reflected and in-place versions.

CodeWrite a Range-like class Span(start, stop) that supports len, in in O(1), indexing with negative indexes and slices returning a new Span, and iteration, without storing the elements.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
class Span:
    def __init__(self, start, stop):
        self.start, self.stop = start, max(start, stop)

    def __len__(self):
        return self.stop - self.start

    def __contains__(self, x):
        return isinstance(x, int) and self.start <= x < self.stop

    def __getitem__(self, i):
        if isinstance(i, slice):
            lo, hi, step = i.indices(len(self))
            if step != 1:
                raise ValueError("step not supported")
            return Span(self.start + lo, self.start + max(lo, hi))
        if i < 0:
            i += len(self)
        if not 0 <= i < len(self):
            raise IndexError("Span index out of range")
        return self.start + i

    def __iter__(self):
        return iter(range(self.start, self.stop))

    def __repr__(self):
        return f"Span({self.start}, {self.stop})"

s = Span(10, 20)
print(len(s), 15 in s, 20 in s, s[-1], s[2:5], list(s[2:5]))
10 True False 19 Span(12, 15) [12, 13, 14]

Without __contains__, 15 in s would still work through __iter__, but in O(n). slice.indices handles None and negative bounds for you.

CodeWrite a Frozen mixin that makes instances immutable after __init__ finishes: assignments and deletions raise AttributeError, but __init__ itself can still set attributes. It should work with __slots__ subclasses too.

Estimated difficulty: about 1500 (Codeforces-style)

Answer

Override __setattr__ and __delattr__, and let __init__ write through object.__setattr__ until a flag is set.

class Frozen:
    __slots__ = ("_frozen",)

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        original = cls.__init__
        def __init__(self, *args, **kw):
            object.__setattr__(self, "_frozen", False)
            original(self, *args, **kw)
            object.__setattr__(self, "_frozen", True)
        cls.__init__ = __init__

    def __setattr__(self, name, value):
        if getattr(self, "_frozen", False):
            raise AttributeError(f"{type(self).__name__} is frozen")
        object.__setattr__(self, name, value)

    def __delattr__(self, name):
        raise AttributeError(f"{type(self).__name__} is frozen")

class Point(Frozen):
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x, p.y)
for action in (lambda: setattr(p, "x", 5), lambda: delattr(p, "y")):
    try:
        action()
    except AttributeError as e:
        print("AttributeError:", e)
1 2
AttributeError: Point is frozen
AttributeError: Point is frozen

__init_subclass__ (Chapter 9) wraps each subclass's __init__ so the flag is set after construction. @dataclass(frozen=True) uses the same object.__setattr__ trick in its generated __init__.

CodeWrite a callable Counter-style class Tally whose instances can be called with an item to count it, support + to merge two tallies, in, len, and bool (false when empty).

Estimated difficulty: about 1200 (Codeforces-style)

Answer
class Tally:
    def __init__(self, counts=None):
        self.counts = dict(counts or {})

    def __call__(self, item):
        self.counts[item] = self.counts.get(item, 0) + 1
        return self

    def __add__(self, other):
        if not isinstance(other, Tally):
            return NotImplemented
        merged = dict(self.counts)
        for k, v in other.counts.items():
            merged[k] = merged.get(k, 0) + v
        return Tally(merged)

    def __contains__(self, item):
        return item in self.counts

    def __len__(self):
        return len(self.counts)

    def __repr__(self):
        return f"Tally({self.counts})"

t = Tally()
for word in "a b a c a".split():
    t(word)
u = Tally()("c")("d")
print(t, t + u, "a" in t, len(t + u), bool(Tally()))
Tally({'a': 3, 'b': 1, 'c': 1}) Tally({'a': 3, 'b': 1, 'c': 2, 'd': 1}) True 4 False

Returning self from __call__ allows chaining, and bool comes for free from __len__.

Cheat sheet

Theory
  • Implicit special method calls look on the type, not the instance, and bypass __getattr__. Changing the class's dunder updates the C slot.
  • a + b: subclass's __radd__ first if it overrides; else a.__add__(b), then b.__radd__(a) on NotImplemented; else TypeError.
  • Return NotImplemented for unsupported operand types; never raise NotImplementedError from an operator.
  • a += b: __iadd__ (must return self), else a = a + b.
  • == falls back to identity and never raises; < raises TypeError when both sides decline. __ne__ inverts __eq__. functools.total_ordering fills in the rest.
  • Truthiness: __bool__ (must return bool), else __len__ != 0, else true.
  • in: __contains__, else __iter__, else __getitem__(0, 1, …) until IndexError.
  • __repr__ for developers, __str__ for users (defaults to repr), __format__ for f-string specs.
  • __slots__: fixed fields via member descriptors, no __dict__, less memory; subclasses must redeclare, add __weakref__ for weak refs, no class-level defaults for slot names.

Part D · Classes and attributes · Chapter 8

Attribute lookup, descriptors and the MRO

"obj.attr" looks like a dictionary lookup, but it runs a precise algorithm that checks the class hierarchy, gives priority to data descriptors, then the instance dict, then everything else. That algorithm is what makes methods bind to self, what property, classmethod and staticmethod are built from, and why __getattr__ and __getattribute__ behave so differently. This chapter also covers the C3 method resolution order and what super() really calls.

TheoryDrillsCoding· about 4 hours· prerequisites: Chapter 7

The lookup algorithm

Theory

obj.name calls type(obj).__getattribute__(obj, "name"). For ordinary objects that is object.__getattribute__, which does this:

  1. Look name up in the class and its bases, following the MRO (below). Call the result cls_attr.
  2. If cls_attr is a data descriptor (its type defines __set__ or __delete__), return cls_attr.__get__(obj, type(obj)). Data descriptors win over the instance.
  3. Otherwise, if name is in the instance's __dict__, return that value.
  4. Otherwise, if cls_attr is a non-data descriptor (defines only __get__), return cls_attr.__get__(obj, type(obj)).
  5. Otherwise, if cls_attr was found, return it as is.
  6. Otherwise raise AttributeError, and if the class defines __getattr__, call __getattr__(obj, name) instead of failing.
find name in type(obj).__mro__ data descriptor? (__set__/__delete__) yes desc.__get__(obj, cls) no name in obj.__dict__? yes obj.__dict__[name] no found on class? (__get__ → call it) __get__ result or value not found:__getattr__
Precedence. Data descriptors on the class beat the instance dict; the instance dict beats methods and other non-data descriptors.

Setting an attribute (obj.name = v) is simpler: if the class has a data descriptor for name, call its __set__; otherwise store into obj.__dict__.

Descriptors

Theory

A descriptor is any object whose type defines __get__, __set__ or __delete__, stored as a class attribute. When accessed through an instance, the lookup algorithm calls those methods instead of returning the object itself.

class Typed:
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = "_" + name
    def __init__(self, kind):
        self.kind = kind
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage)
    def __set__(self, obj, value):
        if not isinstance(value, self.kind):
            raise TypeError(f"{self.name} must be {self.kind.__name__}, got {type(value).__name__}")
        setattr(obj, self.storage, value)

class Order:
    qty = Typed(int)
    price = Typed(float)
    def __init__(self, qty, price):
        self.qty = qty
        self.price = price

o = Order(3, 9.5)
print(o.qty * o.price, o.__dict__)
try:
    o.qty = "3"
except TypeError as e:
    print("TypeError:", e)
print(Order.qty)
28.5 {'_qty': 3, '_price': 9.5}
TypeError: qty must be int, got str
<__main__.Typed object at 0x…>

__set_name__ (3.6+) is called once when the class is created, telling the descriptor which attribute name it was assigned to. __get__ receives obj=None when the attribute is accessed on the class itself, and returning self then is the convention.

Functions are non-data descriptors

A function defined in a class body is just a function stored in the class dict. Functions have a __get__ method, so accessing one through an instance returns a bound method: an object that remembers the function and the instance and inserts the instance as the first argument when called.

class Greeter:
    def hello(self, name):
        return f"hello {name} from {type(self).__name__}"

g = Greeter()
raw = Greeter.__dict__["hello"]
bound = raw.__get__(g, Greeter)
print(type(raw).__name__, type(bound).__name__)
print(bound("Ada"), bound.__self__ is g, bound.__func__ is raw)
print(Greeter.hello is raw, g.hello is g.hello, g.hello == g.hello)
function method
hello Ada from Greeter True True
True False True

Greeter.hello on the class returns the plain function (__get__ with obj=None). Every g.hello creates a new bound-method object, so g.hello is g.hello is False, though they compare equal. Because functions are non-data descriptors, an instance attribute with the same name shadows the method.

property, classmethod and staticmethod

All three are descriptors written in C:

  • property(fget, fset, fdel) is a data descriptor (it always defines __set__, which raises if there is no setter), so it cannot be shadowed by the instance dict.
  • classmethod(f).__get__(obj, cls) returns f bound to cls.
  • staticmethod(f).__get__ returns f unchanged, so no first argument is inserted.
class Celsius:
    def __init__(self, c):
        self._c = c
    @property
    def fahrenheit(self):
        return self._c * 9 / 5 + 32
    @classmethod
    def from_f(cls, f):
        return cls((f - 32) * 5 / 9)
    @staticmethod
    def valid(c):
        return c >= -273.15

t = Celsius.from_f(212)
print(t._c, t.fahrenheit, Celsius.valid(-300))
try:
    t.fahrenheit = 0
except AttributeError as e:
    print("AttributeError:", e)
t.__dict__["fahrenheit"] = "shadow?"
print(t.fahrenheit)
100.0 212.0 False
AttributeError: property 'fahrenheit' of 'Celsius' object has no setter
212.0

Writing straight into __dict__ does not shadow the property: data descriptors are checked before the instance dict.

cached_property: a non-data descriptor on purpose

functools.cached_property defines only __get__. On first access it computes the value and stores it in the instance __dict__ under the same name. From then on the instance dict wins (step 3 beats step 4), so the descriptor is never called again:

from functools import cached_property

class Report:
    calls = 0
    @cached_property
    def total(self):
        Report.calls += 1
        return sum(range(1000))

r = Report()
print(r.total, r.total, Report.calls, "total" in r.__dict__)
del r.total
print(r.total, Report.calls)
499500 499500 1 True
499500 2

Deleting the cached value from the instance dict makes the next access recompute it. Because it needs an instance __dict__, cached_property does not work on classes with __slots__.

__getattr__ and __getattribute__

Theory
  • __getattribute__ is called for every attribute access. Overriding it intercepts everything, including access to your own methods and __dict__, and it is easy to recurse infinitely. Inside it, use super().__getattribute__(name) or object.__getattribute__(self, name).
  • __getattr__ is called only when normal lookup fails. It is the safe hook for lazy attributes, proxies and default values.
class Lazy:
    def __init__(self):
        self.loaded = []
    def __getattr__(self, name):
        if name.startswith("_"):
            raise AttributeError(name)
        value = f"<{name} loaded>"
        self.loaded.append(name)
        setattr(self, name, value)
        return value

class Audited:
    def __init__(self):
        self.x = 1
    def __getattribute__(self, name):
        if not name.startswith("__"):
            print("get", name)
        return super().__getattribute__(name)

lz = Lazy()
print(lz.config, lz.config, lz.loaded)
a = Audited()
print(a.x + a.x)
<config loaded> <config loaded> ['config']
get x
get x
2

Lazy.__getattr__ runs once per name: after setattr, normal lookup finds the instance attribute. Raising AttributeError for underscore names matters, because copy, pickle and hasattr probe for names like __deepcopy__ or __getstate__ and must see a missing attribute, not a fake value.

Interview trap

An AttributeError raised by accident inside a property getter makes Python fall back to __getattr__, so a bug in the property appears as "attribute not found" (or as whatever __getattr__ returns). If a class has both, debug property errors carefully.

Method resolution order (C3)

Theory

With multiple inheritance, Python needs a single order in which to search classes. It uses the C3 linearisation, stored in cls.__mro__. C3 guarantees:

  • A class comes before its bases.
  • The order of bases in the class statement is preserved (class C(A, B) searches A before B).
  • If a class precedes another in some base's MRO, it does so in every subclass's MRO (monotonicity).

The MRO of C(B1, B2) is C followed by a merge of the MROs of B1 and B2 and the list [B1, B2]: repeatedly take the first head that does not appear in the tail of any other list.

class A:
    def who(self): return "A"
class B(A):
    def who(self): return "B"
class C(A):
    def who(self): return "C"
class D(B, C):
    pass

print([k.__name__ for k in D.__mro__], D().who())

class X: pass
class Y: pass
class P(X, Y): pass
class Q(Y, X): pass
try:
    class R(P, Q): pass
except TypeError as e:
    print("TypeError:", e)
['D', 'B', 'C', 'A', 'object'] B
TypeError: Cannot create a consistent method resolution
order (MRO) for bases X, Y

In the diamond, A comes only after both B and C, so A's methods are not reached before C's. R(P, Q) is impossible because P requires X before Y and Q requires Y before X, so C3 refuses to build the class.

What super() does

super() does not mean "the parent class". super().method() inside class K means: take type(self).__mro__, find K in it, and look method up starting from the class after K. The next class depends on the runtime type of self, which is what makes cooperative multiple inheritance work:

class Base:
    def __init__(self):
        print("Base")

class Logging(Base):
    def __init__(self):
        print("Logging ->", end=" ")
        super().__init__()

class Caching(Base):
    def __init__(self):
        print("Caching ->", end=" ")
        super().__init__()

class Service(Logging, Caching):
    def __init__(self):
        print("Service ->", end=" ")
        super().__init__()

Service()
print([k.__name__ for k in Service.__mro__])
Logging()
Service -> Logging -> Caching -> Base
['Service', 'Logging', 'Caching', 'Base', 'object']
Logging -> Base

Inside Logging.__init__, super() refers to Caching when self is a Service, but to Base when self is a plain Logging. Each __init__ runs exactly once. If Logging had called Base.__init__(self) directly, Caching.__init__ would never run.

The zero-argument form works because the compiler gives every method that uses super (or __class__) a hidden cell named __class__ that holds the class being defined, so super() is short for super(__class__, self).

Concept and tricky questions

Drills

ConceptWhat is a descriptor, and what is the difference between a data and a non-data descriptor?

Answer

Answer. An object, stored as a class attribute, whose type defines __get__, __set__ or __delete__. A data descriptor defines __set__ or __delete__ and takes priority over the instance __dict__; a non-data descriptor defines only __get__ and is shadowed by an instance attribute of the same name.

Why. property is a data descriptor (cannot be shadowed); functions, classmethod, staticmethod and cached_property are non-data descriptors.

ConceptHow does obj.method(1) turn into a call with self?

Answer

Answer. Lookup finds the function in the class dict. Functions are non-data descriptors, so function.__get__(obj, type(obj)) returns a bound method holding the function and obj. Calling the bound method calls the function with obj prepended to the arguments.

Why. In 3.11+ the interpreter usually avoids creating the bound method object at all for direct calls (LOAD_ATTR with the method flag followed by CALL), but the semantics are those of the descriptor.

TrickyWhy is obj.method is obj.method False?

Answer

Answer. Each access runs __get__ and creates a new bound-method object. They compare equal (== checks the same function and the same instance), but they are different objects.

Why. This matters when unregistering callbacks: remove(obj.method) works because list.remove uses ==, but a WeakSet of bound methods empties immediately, because each bound method dies as soon as it is created.

ConceptWhen is __getattr__ called and when is __getattribute__ called?

Answer

Answer. __getattribute__ is called on every attribute access. __getattr__ is called only after normal lookup (via __getattribute__) raises AttributeError.

Why. Override __getattr__ for fallbacks, proxies and lazy loading. Overriding __getattribute__ slows down every access and needs super().__getattribute__ to avoid infinite recursion.

TrickyIn class D(B, C) where both B and C inherit from A and all define ping, which ping does D().ping() call, and what does super().ping() inside B.ping call?

Answer

Answer. D.__mro__ is D, B, C, A, object, so D().ping() calls B.ping. Inside it, super().ping() calls C.ping, not A.ping, because super continues from B in the MRO of type(self).

Why. super() follows the instance's MRO, which is why every class in a cooperative hierarchy should call super() and accept **kwargs to pass along.

ConceptWhy does cached_property work, and why can a plain property not cache the same way?

Answer

Answer. cached_property is a non-data descriptor: after it stores the value in the instance __dict__, the instance dict takes precedence and the descriptor is no longer called. A property is a data descriptor that always wins over the instance dict, so storing a value under its name would never be read.

Why. This is a direct application of the lookup order. It also explains why cached_property needs a __dict__ and fails with __slots__.

TrickyWhat does super() with no arguments rely on, and why does it fail in a function defined outside a class and attached later?

Answer

Answer. On a hidden __class__ cell that the compiler creates for functions defined inside a class body that use super or __class__. A function defined outside the class has no such cell, so super() raises RuntimeError: super(): __class__ cell not found.

Why. Use the explicit super(Cls, self) form in such functions.

Predict the output and find the bug

Drills

OutputWhat does this print?

class K:
    def f(self):
        return "method"
    @property
    def p(self):
        return "property"

k = K()
k.__dict__["f"] = lambda: "instance"
k.__dict__["p"] = "instance"
print(k.f(), k.p, K.f(k))
Answer
instance property method

Answer. instance property method. The instance dict shadows the method (a non-data descriptor) but not the property (a data descriptor). Calling through the class bypasses the instance dict.

Why. Lookup checks data descriptors, then the instance dict, then non-data descriptors.

OutputWhat does this print?

class Counter:
    count = 0
    items = []
    def add(self, x):
        self.count += 1
        self.items.append(x)

a, b = Counter(), Counter()
a.add(1); b.add(2)
print(a.count, b.count, Counter.count, a.items, Counter.items is a.items)
Answer
1 1 0 [1, 2] True

Answer. 1 1 0 [1, 2] True. self.count += 1 reads the class attribute 0 and then assigns an instance attribute, so each instance gets its own count. self.items.append mutates the one list stored on the class, so both instances share it.

Why. Reading falls through to the class; assigning always writes the instance dict. Mutable class attributes are shared state.

OutputWhat does this print?

class A:
    def go(self):
        return "A"
class B(A):
    def go(self):
        return "B" + super().go()
class C(A):
    def go(self):
        return "C" + super().go()
class D(C, B):
    def go(self):
        return "D" + super().go()

print(D().go(), B().go())
Answer
DCBA BA

Answer. D.__mro__ is D, C, B, A, object, so the chain is D → C → B → A: DCBA. A plain B has MRO B, A, giving BA.

Why. The same super().go() line inside C calls B.go for a D instance and A.go for a C instance.

OutputWhat does this print?

class Shape:
    sides = 0
    @classmethod
    def describe(cls):
        return f"{cls.__name__} with {cls.sides} sides"
    @staticmethod
    def unit():
        return "cm"

class Square(Shape):
    sides = 4

print(Square.describe(), Square().describe(), Square.unit(), Shape.describe())
Answer
Square with 4 sides Square with 4 sides cm Shape with 0 sides

Answer. Square with 4 sides Square with 4 sides cm Shape with 0 sides. A classmethod receives the class it was called on (or the instance's class), so it sees the subclass's sides.

Why. This is why alternative constructors are written as classmethods: Square.from_json(...) builds a Square, not a Shape.

Find the bugThis lazy proxy recurses forever when copy.copy is used on it. Find and fix the bug.

import copy

class Proxy:
    def __init__(self, target):
        self._target = target
    def __getattr__(self, name):
        if name.startswith("__") or name == "_target":
            raise AttributeError(name)
        return getattr(self._target, name)

p = Proxy([1, 2, 3])
q = copy.copy(p)
print(q.count(2), p.index(3))
Answer
1 2

Answer. copy.copy creates the new object without calling __init__, so _target is missing. The original __getattr__ then handled the lookup of _target by calling getattr(self._target, …) again, recursing until RecursionError. The fix above raises AttributeError for _target and for dunder names, so missing internals look missing.

Why. copy and pickle create instances with cls.__new__(cls) and probe for __reduce_ex__, __setstate__ and friends. Every __getattr__ should refuse names it does not own.

Find the bugA mixin's __init__ never runs. Fix the hierarchy.

class Base:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

class Named(Base):
    def __init__(self, name, **kwargs):
        self.name = name
        super().__init__(**kwargs)

class Timestamped(Base):
    def __init__(self, ts=0, **kwargs):
        self.ts = ts
        super().__init__(**kwargs)

class Event(Named, Timestamped):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

e = Event(name="deploy", ts=42)
print(e.name, e.ts, [k.__name__ for k in Event.__mro__])
Answer
deploy 42 ['Event', 'Named', 'Timestamped', 'Base', 'object']

Answer. The broken version had Named.__init__ call Base.__init__(self) directly, which skipped Timestamped in the MRO, so ts was never set. The fix (above) is cooperative: every class calls super().__init__(**kwargs) and takes only the keyword arguments it understands.

Why. In multiple inheritance you do not know which class comes next, so you cannot name it. Passing the remaining keywords along is the standard pattern.

Write the code

Coding

CodeWrite a Bounded(min, max) descriptor that validates numeric attributes, stores values in the instance __dict__ under the attribute's own name, and still validates on every assignment.

Estimated difficulty: about 1400 (Codeforces-style)

Answer

Because it defines __set__, the descriptor is a data descriptor, so it wins even though the value lives in the instance dict under the same name.

class Bounded:
    def __init__(self, lo, hi):
        self.lo, self.hi = lo, hi
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]
    def __set__(self, obj, value):
        if not self.lo <= value <= self.hi:
            raise ValueError(f"{self.name}={value} not in [{self.lo}, {self.hi}]")
        obj.__dict__[self.name] = value

class Pixel:
    r = Bounded(0, 255)
    g = Bounded(0, 255)
    def __init__(self, r, g):
        self.r, self.g = r, g

p = Pixel(10, 200)
p.r = 255
print(p.r, p.g, p.__dict__)
try:
    p.g = 300
except ValueError as e:
    print("ValueError:", e)
255 200 {'r': 255, 'g': 200}
ValueError: g=300 not in [0, 255]

Storing under the same name is safe here only because data descriptors are checked first. A non-data descriptor doing the same would be bypassed after the first assignment.

CodeImplement my_property, a pure-Python version of property supporting @my_property, .setter and .deleter, and use it in a class.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
class my_property:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget, self.fset, self.fdel = fget, fset, fdel
        self.__doc__ = doc or (fget.__doc__ if fget else None)
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError(f"property {self.name!r} has no getter")
        return self.fget(obj)
    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(f"property {self.name!r} has no setter")
        self.fset(obj, value)
    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(f"property {self.name!r} has no deleter")
        self.fdel(obj)
    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)
    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

class Account:
    def __init__(self):
        self._balance = 0
    @my_property
    def balance(self):
        "Current balance."
        return self._balance
    @balance.setter
    def balance(self, v):
        if v < 0:
            raise ValueError("negative")
        self._balance = v

a = Account()
a.balance = 50
print(a.balance, Account.balance.__doc__)
try:
    del a.balance
except AttributeError as e:
    print("AttributeError:", e)
50 Current balance.
AttributeError: property 'balance' has no deleter

.setter returns a new descriptor with both functions, which is why the setter must reuse the same name: the class body rebinds balance to the new object.

CodeImplement the C3 linearisation: c3(cls) takes a class and returns its MRO computed from __bases__ alone, and check it against __mro__ for a diamond and a deeper hierarchy.

Estimated difficulty: about 1600 (Codeforces-style)

Answer
def c3(cls):
    if cls is object:
        return [object]
    seqs = [c3(b) for b in cls.__bases__] + [list(cls.__bases__)]
    result = [cls]
    while True:
        seqs = [s for s in seqs if s]
        if not seqs:
            return result
        for seq in seqs:
            head = seq[0]
            if not any(head in s[1:] for s in seqs):
                break
        else:
            raise TypeError(f"inconsistent hierarchy for {cls.__name__}")
        result.append(head)
        for s in seqs:
            if s[0] is head:
                del s[0]

class O: pass
class A(O): pass
class B(O): pass
class C(O): pass
class D(A, B): pass
class E(B, C): pass
class F(D, E): pass

for k in (D, F):
    print([x.__name__ for x in c3(k)], c3(k) == list(k.__mro__))
['D', 'A', 'B', 'O', 'object'] True
['F', 'D', 'A', 'E', 'B', 'C', 'O', 'object'] True

A candidate head is accepted only if it does not appear later in any other list, which is what keeps "subclass before base" and the declared base order.

CodeWrite AttrDict, a dict whose keys can also be read and written as attributes (d.name), that still raises AttributeError (not KeyError) for missing names so hasattr and getattr(d, x, default) work.

Estimated difficulty: about 1300 (Codeforces-style)

Answer
class AttrDict(dict):
    def __getattr__(self, name):
        try:
            return self[name]
        except KeyError:
            raise AttributeError(name) from None
    def __setattr__(self, name, value):
        self[name] = value
    def __delattr__(self, name):
        try:
            del self[name]
        except KeyError:
            raise AttributeError(name) from None

cfg = AttrDict(host="localhost")
cfg.port = 8080
print(cfg.host, cfg["port"], hasattr(cfg, "debug"), getattr(cfg, "debug", False))
print(cfg.keys(), cfg)
localhost 8080 False False
dict_keys(['host', 'port']) {'host': 'localhost', 'port': 8080}

Real dict methods such as keys are found by normal lookup, so they win over keys with the same name, which is the main pitfall of this pattern: a key called items cannot be read as an attribute.

Cheat sheet

Theory
  • Lookup: class MRO search → data descriptor __get__ → instance __dict__ → non-data descriptor __get__ → plain class attribute → __getattr__.
  • Assignment: data descriptor __set__ if present, else instance __dict__.
  • Descriptor protocol: __get__(self, obj, objtype), __set__, __delete__, __set_name__(owner, name); return self when obj is None.
  • Functions are non-data descriptors → bound methods (__self__, __func__), a new object per access.
  • property = data descriptor (cannot be shadowed). classmethod binds the class, staticmethod binds nothing, cached_property = non-data descriptor that writes the instance dict.
  • __getattribute__ runs on every access; __getattr__ only on failure. Raise AttributeError for names you do not own (dunders!).
  • C3 MRO: subclasses before bases, declared base order kept, consistent across subclasses; inconsistent orders raise TypeError.
  • super() = next class after the current one in type(self).__mro__; zero-arg form uses the __class__ cell. Cooperative classes all call super().__init__(**kwargs).
  • self.x += 1 on a class attribute creates an instance attribute; mutating a class-level list shares it across instances.

Part D · Classes and attributes · Chapter 9

Classes are objects

A class statement is executable code that builds a new object, an instance of a metaclass (usually type), at the moment it runs. This chapter walks through exactly what happens when a class is created, the difference between __new__ and __init__, when a metaclass is the right tool and when __init_subclass__ or a class decorator is simpler, and what dataclasses, abc and enum generate for you.

TheoryDrillsCoding· about 3.5 hours· prerequisites: Chapters 7–8

type builds classes

Theory

type has two roles. With one argument it returns an object's type. With three, type(name, bases, namespace), it creates a new class. A class statement is a convenient way to make that call:

def greet(self):
    return f"hi, I am {self.name}"

Person = type("Person", (object,), {"species": "human", "greet": greet, "__init__": lambda self, n: setattr(self, "name", n)})
p = Person("Ada")
print(p.greet(), Person.__name__, Person.__bases__, type(Person))

class Person2:
    species = "human"
print(type(Person2) is type, isinstance(Person2, type), isinstance(type, type))
hi, I am Ada Person (<class 'object'>,) <class 'type'>
True True True

So every class is an object: you can pass it to functions, store it in a dict, add attributes to it and create it at run time. Its type is its metaclass, which for ordinary classes is type.

What happens when a class statement runs

Theory

For

class Order(Base, metaclass=Meta, flag=True):
    qty = Field()
    def total(self): ...

Python does the following, in order:

  1. Resolve the metaclass: the explicit metaclass=, or else the most derived metaclass of the bases (type if none). Conflicting metaclasses raise TypeError.
  2. Prepare the namespace: call Meta.__prepare__("Order", bases, flag=True), which returns the mapping the body will execute in (a plain dict by default).
  3. Execute the class body in that namespace, like a function body. qty = Field() and def total simply store entries in it. __module__ and __qualname__ are set first.
  4. Create the class: call Meta("Order", bases, namespace, flag=True), which runs Meta.__new__ and then Meta.__init__. Inside type.__new__:

    • the C slots are filled from the dunder methods;
    • __set_name__(owner, name) is called on every attribute that defines it (descriptors learn their names);
    • __init_subclass__(**kwargs) of the parent class is called with the extra keywords (flag=True).
  5. Apply class decorators, innermost first, and bind the result to the name Order.
class Meta(type):
    @classmethod
    def __prepare__(mcls, name, bases, **kw):
        print("1. __prepare__", name, kw)
        return {}
    def __new__(mcls, name, bases, ns, **kw):
        print("3. Meta.__new__", sorted(k for k in ns if not k.startswith("__")))
        return super().__new__(mcls, name, bases, ns, **kw)
    def __init__(cls, name, bases, ns, **kw):
        print("5. Meta.__init__")
        super().__init__(name, bases, ns, **kw)

class Field:
    def __set_name__(self, owner, name):
        print("4a. __set_name__", name)

class Base:
    def __init_subclass__(cls, **kw):
        print("4b. __init_subclass__", cls.__name__, kw)

def decorate(cls):
    print("6. decorator")
    return cls

@decorate
class Order(Base, metaclass=Meta, flag=True):
    print("2. body runs")
    qty = Field()
1. __prepare__ Order {'flag': True}
2. body runs
3. Meta.__new__ ['qty']
4a. __set_name__ qty
4b. __init_subclass__ Order {'flag': True}
5. Meta.__init__
6. decorator

__set_name__ and __init_subclass__ both run inside type.__new__, before the metaclass's __init__. The metaclass must pass the extra keywords (flag=True) on to type.__new__, or __init_subclass__ never sees them.

__new__ and __init__

Theory

Calling a class, Order(3), calls type(Order).__call__(Order, 3), which does:

  1. obj = Order.__new__(Order, 3): allocate and return the new object. It is a static method that receives the class.
  2. If obj is an instance of Order, call obj.__init__(3) to initialise it. If __new__ returned something else, __init__ is skipped.

Most classes only need __init__. You need __new__ when the object must be configured before it exists, mainly for subclasses of immutable types, where __init__ is too late, and for controlling instance creation (caching, singletons):

class Celsius(float):
    def __new__(cls, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        return super().__new__(cls, round(value, 2))
    def __repr__(self):
        return f"{float(self)}°C"

class UpperInit(str):
    def __init__(self, s):
        self.original = s
        s = s.upper()          # too late: the str value is already fixed

class Upper(str):
    def __new__(cls, s):
        return super().__new__(cls, s.upper())

t = Celsius(21.456)
print(t, t + 1, isinstance(t, float))
print(UpperInit("abc"), UpperInit("abc").original, Upper("abc"))

class Cached:
    _cache = {}
    def __new__(cls, key):
        if key not in cls._cache:
            cls._cache[key] = super().__new__(cls)
        return cls._cache[key]
    def __init__(self, key):
        print("init", key)
        self.key = key

a, b = Cached("x"), Cached("x")
print(a is b)
21.46°C 22.46 True
abc abc ABC
init x
init x
True

Note that Cached("x") runs __init__ every time, even when __new__ returns the cached instance, because the returned object is an instance of the class. That re-initialisation is a common bug in naive singletons.

Metaclasses and their lighter alternatives

Theory

A metaclass customises class creation and class-level behaviour: methods on the metaclass become methods of the class object (not of instances). Uses in the standard library include abc.ABCMeta (tracks abstract methods), enum.EnumType (called EnumMeta before 3.11; it turns class attributes into members and makes the class iterable) and the old typing internals.

class PluginMeta(type):
    registry = {}
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:
            mcls.registry[ns.get("name", name.lower())] = cls
        return cls
    def __len__(cls):
        return len(PluginMeta.registry)
    def __iter__(cls):
        return iter(PluginMeta.registry)

class Plugin(metaclass=PluginMeta):
    pass

class Csv(Plugin):
    name = "csv"

class Json(Plugin):
    pass

print(dict(PluginMeta.registry).keys(), len(Plugin), list(Plugin))
dict_keys(['csv', 'json']) 2 ['csv', 'json']

len(Plugin) works because special method lookup goes to the type of Plugin, which is PluginMeta (Chapter 7).

Prefer __init_subclass__ or a decorator

Most "register every subclass" or "validate subclasses" jobs no longer need a metaclass. __init_subclass__ (3.6+, PEP 487) is a hook on the base class that runs for each new subclass, and it composes with other bases, while two unrelated metaclasses conflict:

class Plugin:
    registry = {}
    def __init_subclass__(cls, /, name=None, **kw):
        super().__init_subclass__(**kw)
        if not hasattr(cls, "run"):
            raise TypeError(f"{cls.__name__} must define run()")
        Plugin.registry[name or cls.__name__.lower()] = cls

class Csv(Plugin, name="csv"):
    def run(self):
        return "parsing csv"

try:
    class Broken(Plugin):
        pass
except TypeError as e:
    print("TypeError:", e)

print(Plugin.registry, Plugin.registry["csv"]().run())

class M1(type): pass
class M2(type): pass
class A(metaclass=M1): pass
class B(metaclass=M2): pass
try:
    class C(A, B): pass
except TypeError as e:
    print("TypeError:", str(e)[:60])
TypeError: Broken must define run()
{'csv': <class '__main__.Csv'>} parsing csv
TypeError: metaclass conflict: the metaclass of a derived class must be

Key idea

Reach for tools in this order: a plain function or class decorator, then __init_subclass__ or __set_name__, and only then a metaclass, when you need to change the namespace (__prepare__), the class's own behaviour (len(cls), cls[...]) or how the class itself is called.

What dataclasses, abc and enum generate

Theory

dataclasses

@dataclass is a class decorator. It reads the class's __annotations__ and generates source code for __init__, __repr__ and __eq__ (plus ordering, __hash__, __slots__ and frozen behaviour on request), then compiles it with exec:

from dataclasses import dataclass, field, fields
import inspect

@dataclass(order=True, frozen=True, slots=True)
class Item:
    priority: int
    name: str = field(compare=False)
    tags: tuple = ()

a, b = Item(2, "b"), Item(1, "a")
print(sorted([a, b]), a == Item(2, "other"))
print([f.name for f in fields(Item)], hasattr(a, "__dict__"), hash(a) == hash(Item(2, "z")))
print(inspect.signature(Item.__init__))
try:
    a.priority = 5
except AttributeError as e:
    print(type(e).__name__)
[Item(priority=1, name='a', tags=()), Item(priority=2, name='b', tags=())] True
['priority', 'name', 'tags'] False True
(self, priority: int, name: str, tags: tuple = ()) -> None
FrozenInstanceError

compare=False removes name from equality, ordering and the hash. Mutable defaults must use field(default_factory=list); @dataclass raises ValueError for a bare [] default, for the reason in Chapter 2.

abc

ABCMeta records the names decorated with @abstractmethod in __abstractmethods__, and object.__new__ refuses to instantiate a class while that set is non-empty. It also supports virtual subclasses through register and __subclasshook__, which is how isinstance(x, collections.abc.Iterable) can be true for classes that never inherited from it:

from abc import ABC, abstractmethod
from collections.abc import Sized, Iterable

class Store(ABC):
    @abstractmethod
    def get(self, key): ...
    def get_or(self, key, default):
        try:
            return self.get(key)
        except KeyError:
            return default

class DictStore(Store):
    def __init__(self):
        self.d = {}
    def get(self, key):
        return self.d[key]

try:
    Store()
except TypeError as e:
    print("TypeError:", e)
print(DictStore().get_or("x", 0), Store.__abstractmethods__)

class Bag:
    def __len__(self):
        return 0
print(isinstance(Bag(), Sized), isinstance(Bag(), Iterable), issubclass(list, Iterable))
TypeError: Can't instantiate abstract class Store without an implementation for abstract method 'get'
0 frozenset({'get'})
True False True

Sized.__subclasshook__ returns True for any class with __len__, so Bag counts as Sized without inheriting. That is structural ("duck") typing checked at run time.

enum

EnumType uses __prepare__ to give the class body a special namespace that rejects duplicate names, then turns each attribute into a singleton member instance. The class itself becomes iterable and indexable by name:

from enum import Enum, IntEnum, auto

class Color(Enum):
    RED = 1
    GREEN = 2
    CRIMSON = 1          # alias of RED

class Level(IntEnum):
    LOW = auto()
    HIGH = auto()

print(list(Color), Color(1), Color["GREEN"], Color.CRIMSON is Color.RED)
print(Color.RED == 1, Level.HIGH == 2, Level.HIGH > Level.LOW, type(Color).__name__)
try:
    Color.RED = 5
except AttributeError as e:
    print("AttributeError:", e)
[<Color.RED: 1>, <Color.GREEN: 2>] Color.RED Color.GREEN True
False True True EnumType
AttributeError: cannot reassign member 'RED'

A plain Enum member is not equal to its value, which prevents accidentally comparing a colour with an unrelated int; IntEnum members are ints and compare as such.

Concept and tricky questions

Drills

ConceptWhat is a metaclass, and what is the metaclass of an ordinary class?

Answer

Answer. The type of a class: the thing that creates the class object and defines class-level behaviour. For ordinary classes it is type. type is its own metaclass.

Why. Instances are created by calling a class; classes are created by calling a metaclass with (name, bases, namespace).

ConceptList the steps between the start of a class statement and the class name being bound.

Answer

Answer. Resolve the metaclass, call __prepare__ to get the namespace, run the body in it, call metaclass(name, bases, ns, **kw) (__new__, which fills slots and calls __set_name__ and the parent's __init_subclass__, then __init__), apply class decorators, bind the name.

Why. Knowing the order explains why a descriptor's __set_name__ runs before a class decorator sees the class, and why the class body cannot refer to the class by name (it does not exist yet).

ConceptWhen do you need __new__ instead of __init__?

Answer

Answer. When the value must be fixed at allocation: subclasses of immutable types (int, float, str, tuple, bytes, frozenset), and when you want to return an existing object (caching, interning, singletons) or an object of a different class.

Why. By the time __init__ runs, an immutable object's value is already set. __new__ is a static method that receives the class and must return the object.

TrickyA singleton returns a cached instance from __new__. Why are its fields reset every time someone calls the class?

Answer

Answer. type.__call__ calls __init__ on whatever __new__ returns, as long as it is an instance of the class. So the cached instance is re-initialised on every call.

Why. Guard __init__ with a flag, move initialisation into __new__, use a module-level instance, or use a metaclass whose __call__ returns the cached instance without calling __init__ again.

ConceptWhat does __init_subclass__ replace, and why is it usually better than a metaclass?

Answer

Answer. It replaces metaclasses written only to react to subclass creation (registration, validation, adding attributes). It is an ordinary class method on the base, cooperates through super(), accepts keyword arguments from the class statement, and does not create metaclass conflicts when combined with other bases.

Why. Two bases with unrelated metaclasses cannot be combined (TypeError: metaclass conflict); two bases with __init_subclass__ can.

Trickyisinstance(obj, collections.abc.Iterable) is True for a class that never inherits from Iterable. How?

Answer

Answer. Iterable is an ABC whose metaclass ABCMeta calls Iterable.__subclasshook__, which returns True for any class with an __iter__ method in its MRO. Classes can also be added explicitly with Iterable.register(cls).

Why. It is run-time structural typing. Note the hook checks only for the method name; Iterable does not detect classes iterable through the __getitem__ protocol.

ConceptWhat code does @dataclass generate, and how?

Answer

Answer. By default __init__, __repr__ and __eq__; with options also __lt__ and friends (order=True), __hash__ (frozen=True or unsafe_hash=True), __setattr__/__delattr__ that raise (frozen=True), __slots__ (slots=True, which builds a new class) and __match_args__. It builds the source text of these methods from the annotated fields and compiles it with exec.

Why. Because it generates real methods, a dataclass has no per-call overhead compared with handwritten code.

Predict the output and find the bug

Drills

OutputWhat does this print?

class Tracker:
    def __set_name__(self, owner, name):
        print("set_name", name)

class Base:
    def __init_subclass__(cls):
        print("init_subclass", cls.__name__)

print("before")
class Child(Base):
    print("body")
    a = Tracker()
    b = Tracker()
print("after")
Answer
before
body
set_name a
set_name b
init_subclass Child
after

Answer. before, body, then set_name a, set_name b (in definition order), then init_subclass Child, then after.

Why. The body runs first; type.__new__ then calls __set_name__ on each attribute and, after that, the parent's __init_subclass__.

OutputWhat does this print?

class Weird:
    def __new__(cls):
        return 42
    def __init__(self):
        print("init called")

class Child:
    def __new__(cls):
        print("new")
        return super().__new__(cls)
    def __init__(self):
        print("init")

print(Weird(), type(Weird()))
Child()
Answer
42 <class 'int'>
new
init

Answer. 42 <class 'int'> with no init called, then new, init.

Why. __init__ runs only if __new__ returns an instance of the class. Returning anything else skips it.

OutputWhat does this print?

from dataclasses import dataclass, field

@dataclass
class Team:
    name: str
    members: list = field(default_factory=list)
    count = 0

a, b = Team("a"), Team("b")
a.members.append("x")
print(a, b.members, Team.count, "count" in Team.__dataclass_fields__)
Answer
Team(name='a', members=['x']) [] 0 False

Answer. Team(name='a', members=['x']) [] 0 False. default_factory gives each instance its own list. count has no annotation, so it is a plain class attribute, not a field, and it is not in the repr or __init__.

Why. Dataclasses use annotations to find fields. ClassVar[int] annotations are also excluded.

OutputWhat does this print?

from enum import Enum

class Status(Enum):
    OK = "ok"
    FAIL = "fail"

print(Status("ok"), Status.OK.value, Status.OK == "ok", Status.OK.name)
print(isinstance(Status.OK, Status), len(Status), "OK" in Status.__members__)
Answer
Status.OK ok False OK
True 2 True

Answer. Status.OK ok False OK, then True 2 True. Calling the enum class with a value looks up the member; members are instances of the enum class; plain Enum members do not equal their values.

Why. Use StrEnum (3.11+) or IntEnum when members must compare equal to their raw values, for example when they come from JSON.

Find the bugThis abstract base class can still be instantiated. Find the bug.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class NoArea(Shape):
    pass

class Square(Shape):
    def __init__(self, s):
        self.s = s
    def area(self):
        return self.s ** 2

try:
    NoArea()
except TypeError as e:
    print("TypeError:", e)
print(Square(3).area())
Answer
TypeError: Can't instantiate abstract class NoArea without an implementation for abstract method 'area'
9

Answer. The original Shape did not inherit from ABC (or use metaclass=ABCMeta), so @abstractmethod had no effect and NoArea() succeeded. With ABC as a base, as above, instantiating a class with abstract methods raises TypeError.

Why. The check lives in object.__new__ and only looks at __abstractmethods__, which only ABCMeta computes.

Find the bugA config singleton loses its settings. Fix it.

class Config:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.settings = {}
        return cls._instance

c1 = Config()
c1.settings["debug"] = True
c2 = Config()
print(c2.settings, c1 is c2)
Answer
{'debug': True} True

Answer. The buggy version created self.settings = {} in __init__, which runs on every Config() call and wiped the settings. The fix moves the one-time setup into __new__, inside the "first time" branch, as above.

Why. In Python the simplest singleton is a module-level instance: modules are imported once and cached in sys.modules.

Write the code

Coding

CodeWrite a metaclass OrderedFields whose __prepare__ returns a namespace that records the order in which non-callable class attributes were defined, and stores it on the class as _field_order, then raises TypeError if the same name is assigned twice in the class body.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
class RecordingNamespace(dict):
    def __init__(self):
        super().__init__()
        self.order = []
    def __setitem__(self, key, value):
        if key in self and not key.startswith("__"):
            raise TypeError(f"duplicate definition of {key!r}")
        if not key.startswith("__") and not callable(value):
            self.order.append(key)
        super().__setitem__(key, value)

class OrderedFields(type):
    @classmethod
    def __prepare__(mcls, name, bases):
        return RecordingNamespace()
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, dict(ns))
        cls._field_order = tuple(ns.order)
        return cls

class Row(metaclass=OrderedFields):
    id = 0
    name = ""
    def show(self):
        return "..."
    created = None

print(Row._field_order)
try:
    class Bad(metaclass=OrderedFields):
        x = 1
        x = 2
except TypeError as e:
    print("TypeError:", e)
('id', 'name', 'created')
TypeError: duplicate definition of 'x'

__prepare__ is the only hook that can see the body while it executes, which is why enum uses it to reject duplicate member names. Since 3.6 a plain dict already preserves definition order, so the recording is for the filtering and the duplicate check.

CodeWrite a class decorator auto_repr that adds a __repr__ showing the arguments of __init__ (using inspect.signature) and their current values on the instance.

Estimated difficulty: about 1300 (Codeforces-style)

Answer
import inspect

def auto_repr(cls):
    params = [p for p in inspect.signature(cls.__init__).parameters if p != "self"]
    def __repr__(self):
        args = ", ".join(f"{p}={getattr(self, p, '?')!r}" for p in params)
        return f"{type(self).__name__}({args})"
    cls.__repr__ = __repr__
    return cls

@auto_repr
class Job:
    def __init__(self, name, retries=3):
        self.name = name
        self.retries = retries

print(Job("backup"), [Job("a", 1)])
Job(name='backup', retries=3) [Job(name='a', retries=1)]

A decorator runs after the class exists, so it can inspect the finished class and add methods, which covers most cases people reach for metaclasses for.

CodeWrite Singleton, a metaclass whose __call__ creates the instance once per class and returns it on later calls without calling __init__ again. Show it works for two different classes and for a subclass.

Estimated difficulty: about 1600 (Codeforces-style)

Answer
class Singleton(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in Singleton._instances:
            Singleton._instances[cls] = super().__call__(*args, **kwargs)
        return Singleton._instances[cls]

class Database(metaclass=Singleton):
    def __init__(self, url="sqlite://"):
        print("connecting to", url)
        self.url = url

class Cache(metaclass=Singleton):
    pass

class TestDatabase(Database):
    pass

a = Database("postgres://prod")
b = Database("ignored")
print(a is b, b.url, Cache() is Cache(), TestDatabase() is a)
connecting to postgres://prod
connecting to sqlite://
True postgres://prod True False

Overriding __call__ on the metaclass intercepts Database(...) before __new__ and __init__, which is the only way to skip __init__ for the cached instance. Keying by cls gives each subclass its own instance.

CodeWrite a minimal @record decorator, a tiny dataclass: read cls.__annotations__, generate __init__ with defaults taken from class attributes, plus __repr__ and __eq__, by building source code and calling exec, as dataclasses does.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
def record(cls):
    names = list(cls.__annotations__)
    defaults = {n: getattr(cls, n) for n in names if hasattr(cls, n)}
    params = ", ".join(f"{n}=__d_{n}" if n in defaults else n for n in names)
    body = "\n".join(f"    self.{n} = {n}" for n in names) or "    pass"
    src = f"def __init__(self, {params}):\n{body}\n"
    ns = {f"__d_{n}": v for n, v in defaults.items()}
    exec(src, ns)
    cls.__init__ = ns["__init__"]
    cls.__repr__ = lambda self: f"{cls.__name__}(" + ", ".join(f"{n}={getattr(self, n)!r}" for n in names) + ")"
    cls.__eq__ = lambda self, other: type(other) is type(self) and all(getattr(self, n) == getattr(other, n) for n in names)
    cls.__hash__ = None
    return cls

@record
class Point:
    x: int
    y: int = 0

print(Point(1), Point(1, 2) == Point(1, 2), Point(x=3, y=4))
import inspect
print(inspect.signature(Point.__init__))
Point(x=1, y=0) True Point(x=3, y=4)
(self, x, y=0)

Generating and compiling real source gives a normal function with a proper signature, which is faster than a generic __init__(self, *args, **kwargs) loop and works with inspect and IDEs.

Cheat sheet

Theory
  • type(obj) gives the type; type(name, bases, ns) creates a class. Classes are instances of their metaclass (type by default).
  • Class creation: metaclass → __prepare__ → body runs → Meta(name, bases, ns) (__new__: slots, __set_name__, parent __init_subclass__; then __init__) → decorators → bind name.
  • Calling a class: type.__call__ → __new__(cls, …) → __init__ only if the result is an instance of cls.
  • __new__ for immutable subclasses and instance control; returning a cached instance still triggers __init__.
  • Metaclass methods are class-level behaviour (len(cls), cls[...]); unrelated metaclasses conflict.
  • Prefer decorators, __init_subclass__ and __set_name__ over metaclasses.
  • @dataclass generates real methods via exec; use field(default_factory=…) for mutable defaults; un-annotated attributes are not fields.
  • ABCMeta: __abstractmethods__ blocks instantiation; register and __subclasshook__ give virtual subclasses (isinstance(x, Sized)).
  • Enum members are singleton instances, iterable via the class; plain members ≠ values, IntEnum/StrEnum members do equal them.

Part E · Control flow and concurrency · Chapter 10

Iterators, generators and coroutines

A generator is a function whose frame can be paused and resumed, and that one mechanism underlies lazy pipelines, yield from delegation, async/await and the asyncio event loop. This chapter covers the iterator protocol, what a suspended generator holds, send, throw and close, how coroutines are built on the same machinery, and how asyncio schedules them on a single thread.

TheoryDrillsCoding· about 4 hours· prerequisites: Chapters 2–3

The iterator protocol

Theory

Two roles, two methods:

  • An iterable is anything with __iter__ that returns an iterator (lists, dicts, files, ranges). It can usually be iterated many times.
  • An iterator has __next__, which returns the next value or raises StopIteration, and an __iter__ that returns itself. It is single-pass and keeps its position.

A for loop is shorthand for calling iter() once and then next() until StopIteration:

items = [10, 20]
it = iter(items)
print(it is iter(it), next(it), next(it))
try:
    next(it)
except StopIteration:
    print("exhausted")
print(list(it), list(items))

it = iter(items)
for x in it:
    print("loop", x)
    break
print("resumed:", list(it))
True 10 20
exhausted
[] [10, 20]
loop 10
resumed: [20]

Iterating the iterator again gives nothing, while the list can be iterated any number of times. Breaking out of a for loop leaves the iterator where it was, so a second loop over the same iterator continues.

Interview trap

A function that loops over its argument twice works with a list and silently gets an empty second pass with a generator or file. If you need two passes, convert to a list first or use itertools.tee.

Generators

Theory

A function containing yield is a generator function. Calling it runs no code; it returns a generator object holding a fresh frame. Each next() resumes the frame until the next yield, which hands a value out and suspends the frame again with all its locals and its instruction pointer intact.

import inspect

def countdown(n):
    print("started")
    while n > 0:
        yield n
        n -= 1
    print("finished")
    return "liftoff"

g = countdown(2)
print(inspect.getgeneratorstate(g))
print(next(g), inspect.getgeneratorstate(g), g.gi_frame.f_locals)
print(next(g))
try:
    next(g)
except StopIteration as stop:
    print("return value:", stop.value)
print(inspect.getgeneratorstate(g), g.gi_frame)
GEN_CREATED
started
2 GEN_SUSPENDED {'n': 2}
1
finished
return value: liftoff
GEN_CLOSED None

Nothing prints until the first next. When the function returns, the generator raises StopIteration carrying the return value, and its frame is released. Since 3.11 a suspended generator's frame lives inside the generator object itself, so creating one is cheap.

Laziness

Because values are produced on demand, a pipeline of generators processes one item at a time and never builds the intermediate lists:

import sys

def read_numbers(n):
    for i in range(n):
        yield i

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))
print(sys.getsizeof(squares_list) > 8_000_000, sys.getsizeof(squares_gen) < 300)

pipeline = (x * x for x in read_numbers(10**12) if x % 3 == 0)
print([next(pipeline) for _ in range(4)])
True True
[0, 9, 36, 81]

A pipeline over a trillion numbers still answers instantly, because only four values are ever computed.

A generator expression evaluates its first iterable immediately (when the expression is created), and everything else lazily. That is why an error in the outermost iterable appears at once, while errors in the rest appear only when you iterate.

send, throw and close

yield is an expression. gen.send(value) resumes the generator and makes the paused yield evaluate to value. The first resume must be next(gen) or gen.send(None), to run up to the first yield.

def running_average():
    total, count = 0.0, 0
    average = None
    while True:
        value = yield average
        total += value
        count += 1
        average = total / count

avg = running_average()
next(avg)                  # prime it
print(avg.send(10), avg.send(20), avg.send(60))
10.0 15.0 30.0

gen.throw(exc) raises an exception at the paused yield, and gen.close() throws GeneratorExit there, which lets finally blocks and with statements inside the generator clean up. Close is called automatically when a generator is garbage collected:

def reader():
    print("open resource")
    try:
        while True:
            yield "line"
    finally:
        print("close resource")

r = reader()
print(next(r), next(r))
r.close()
print("--")
r2 = reader()
next(r2)
del r2
print("after del")
open resource
line line
close resource
--
open resource
close resource
after del

A generator must not yield after catching GeneratorExit; doing so raises RuntimeError: generator ignored GeneratorExit.

PEP 479: StopIteration inside a generator

Before 3.7, a StopIteration that leaked out of code called inside a generator (for example an unguarded next(it)) silently ended the generator, hiding the bug. Since 3.7 it is converted into a RuntimeError:

def firsts(iterables):
    for it in iterables:
        yield next(iter(it))

try:
    print(list(firsts([[1], [], [3]])))
except RuntimeError as e:
    print("RuntimeError:", e)
RuntimeError: generator raised StopIteration

yield from

Theory

yield from sub delegates to another generator: every value sub yields passes straight to the caller, every send and throw is forwarded into sub, and when sub returns, its return value becomes the value of the yield from expression.

def tokens(line):
    for word in line.split():
        yield word
    return len(line.split())

def document(lines):
    total = 0
    for line in lines:
        total += yield from tokens(line)
    return total

g = document(["a b", "c d e"])
out = []
while True:
    try:
        out.append(next(g))
    except StopIteration as stop:
        print(out, "count =", stop.value)
        break

def flatten(xs):
    for x in xs:
        if isinstance(x, list):
            yield from flatten(x)
        else:
            yield x
print(list(flatten([1, [2, [3, [4]], 5]])))
['a', 'b', 'c', 'd', 'e'] count = 5
[1, 2, 3, 4, 5]

yield from is what made generator-based coroutines possible before async/await, because a whole chain of generators could be suspended and resumed through the outermost one.

Coroutines and async/await

Theory

async def defines a coroutine function. Calling it returns a coroutine object, which is built on the same suspended-frame machinery as a generator. await x suspends the coroutine until x (another coroutine, a Future or any object with __await__) completes, in the way yield from would.

A coroutine does nothing by itself. Something must drive it by calling its send method, and that something is an event loop:

import types

@types.coroutine
def suspend(tag):
    reply = yield tag          # hands control to whoever drives us
    return reply

async def worker():
    a = await suspend("need A")
    b = await suspend("need B")
    return a + b

coro = worker()
print(type(coro).__name__)
print(coro.send(None))
print(coro.send(10))
try:
    coro.send(5)
except StopIteration as stop:
    print("result:", stop.value)
coroutine
need A
need B
result: 15

The coroutine yields a request up through the await chain, the driver decides what to do and sends the answer back in. asyncio does exactly this: what reaches it are Future objects, and it resumes the coroutine when the future completes.

The asyncio event loop

An asyncio program runs on one thread. The loop keeps:

  • a queue of ready callbacks to run now;
  • a heap of scheduled callbacks ordered by time (from sleep, timeouts);
  • a selector (epoll on Linux, kqueue on macOS) watching sockets and pipes.

Each iteration it runs every ready callback, then waits in the selector until a socket is ready or the next timer is due, and moves the corresponding callbacks to the ready queue. A Task wraps a coroutine: its step function calls coro.send(None), and when the coroutine awaits a pending future, the task adds a "wake me" callback to that future and returns to the loop.

ready queuetask steps, callbacks selector.select()epoll: sockets, pipes timer heapsleep, timeouts task.step()coro.send(None) until await queue empty: wait fd ready timer due run
One thread, cooperative. A task runs until it awaits something that is not ready; only then can another task run. Anything that blocks without awaiting stops every task.
import asyncio, time

async def fetch(name, delay):
    print("start", name)
    await asyncio.sleep(delay)
    print("done", name)
    return name.upper()

async def main():
    t0 = time.perf_counter()
    results = await asyncio.gather(fetch("a", 0.2), fetch("b", 0.1), fetch("c", 0.15))
    elapsed = time.perf_counter() - t0
    print(results, "concurrent:", elapsed < 0.3)

asyncio.run(main())
start a
start b
start c
done b
done c
done a
['A', 'B', 'C'] concurrent: True

All three start before any finishes, and the total time is about the longest delay, not the sum, because each await asyncio.sleep hands control back to the loop. gather returns results in argument order, not completion order.

Blocking the loop

time.sleep, CPU-heavy loops, synchronous HTTP clients and most file APIs do not await, so they freeze the whole loop:

import asyncio, time

async def ticker(log):
    for _ in range(3):
        log.append(round(time.perf_counter() - START, 1))
        await asyncio.sleep(0.1)

async def bad():
    time.sleep(0.3)              # blocks the loop

async def good():
    await asyncio.to_thread(time.sleep, 0.3)

async def run(blocker):
    log = []
    await asyncio.gather(ticker(log), blocker())
    return log

START = time.perf_counter()
bad_log = asyncio.run(run(bad))
START = time.perf_counter()
good_log = asyncio.run(run(good))
print("blocked ticks delayed:", bad_log[1] >= 0.3, "| thread kept ticking:", good_log[1] < 0.2)
blocked ticks delayed: True | thread kept ticking: True

asyncio.to_thread (3.9+) runs a blocking function in a thread pool and gives the loop an awaitable for its result; loop.run_in_executor does the same with a chosen executor, including process pools for CPU work.

Key idea

async gives concurrency, not parallelism. It is ideal for many slow I/O operations (thousands of sockets on one thread), useless for CPU-bound work, and every blocking call inside a coroutine is a bug.

Concept and tricky questions

Drills

ConceptWhat is the difference between an iterable and an iterator?

Answer

Answer. An iterable has __iter__ returning an iterator and can usually be iterated many times. An iterator has __next__ (and __iter__ returning itself), keeps its position, and is exhausted after one pass.

Why. for calls iter() on the iterable once. A list is iterable; iter(list) is an iterator; a generator is an iterator.

ConceptWhat happens when you call a generator function?

Answer

Answer. None of its body runs. It returns a generator object holding a new frame positioned at the start. The body runs only when next() or send() resumes it, up to the next yield.

Why. That is why argument validation in a generator function is delayed until the first next. Wrap the generator in a normal function that validates first and then returns the generator.

ConceptWhat do send, throw and close do to a suspended generator?

Answer

Answer. send(v) resumes it and makes the current yield expression return v. throw(exc) resumes it by raising exc at the yield. close() raises GeneratorExit at the yield so that finally blocks run; the generator must then finish without yielding again.

Why. These three methods are the whole interface an event loop needs to drive coroutines.

TrickyWhy does a StopIteration raised inside a generator become a RuntimeError?

Answer

Answer. PEP 479 (default since 3.7). Previously it silently terminated the generator as if it had returned, which hid bugs such as an unguarded next() on an empty iterator.

Why. To end a generator, return. To handle an empty iterator, use next(it, default) or catch StopIteration explicitly.

ConceptWhat does yield from do that a for loop with yield does not?

Answer

Answer. It forwards send, throw and close into the sub-generator, and it evaluates to the sub-generator's return value. A for … yield loop only passes values out.

Why. Full delegation is what let chains of generator-based coroutines be suspended as one unit, which await now does for native coroutines.

TrickyCalling fetch() where fetch is an async def function seems to do nothing. Why, and what warning appears?

Answer

Answer. Calling a coroutine function only creates a coroutine object; nothing runs until it is awaited or wrapped in a task. When the unused coroutine is garbage collected, Python warns RuntimeWarning: coroutine 'fetch' was never awaited.

Why. Use await fetch() inside a coroutine, asyncio.create_task(fetch()) to run it concurrently, or asyncio.run(fetch()) at the top level.

ConceptIs asyncio parallel? When does a task give up control?

Answer

Answer. No. All tasks run on one thread, so only one runs at a time. A task gives up control only at an await on something that is not ready yet (a pending future, asyncio.sleep, I/O). Between awaits it runs uninterrupted.

Why. This makes many race conditions impossible between awaits, but one blocking call stalls everything.

Predict the output and find the bug

Drills

OutputWhat does this print?

def numbers():
    yield 1
    yield 2

gen = numbers()
print(sum(gen), sum(gen))
nums = [1, 2]
print(sum(nums), sum(nums))
print(3 in (x for x in [1, 2, 3, 4]), list(x for x in range(3)))
Answer
3 0
3 3
True [0, 1, 2]

Answer. 3 0: the second sum gets an exhausted generator. The list sums to 3 twice. Membership on a generator consumes it until it finds the value.

Why. Generators are single-pass iterators. in on an iterator is a linear search that leaves the iterator positioned after the match.

OutputWhat does this print?

def gen():
    print("gen body starts")
    yield 1

g = gen()
print("created")
x = (i * 2 for i in [print("first iterable"), 1][1:])
print("genexp created")
print(next(g), list(x))
Answer
created
first iterable
genexp created
gen body starts
1 [2]

Answer. created, then first iterable (the outermost iterable of a generator expression is evaluated immediately), then genexp created, then gen body starts and 1 [2].

Why. A generator function runs nothing until next; a generator expression evaluates its first for clause's iterable at creation time.

OutputWhat does this print?

def echo():
    received = []
    while True:
        x = yield len(received)
        if x is None:
            break
        received.append(x)
    return received

e = echo()
print(next(e), e.send("a"), e.send("b"))
try:
    e.send(None)
except StopIteration as s:
    print(s.value)
Answer
0 1 2
['a', 'b']

Answer. 0 1 2 then ['a', 'b']. Priming yields 0; each send appends and yields the new length; sending None breaks the loop and the return value travels in StopIteration.value.

Why. The value of a yield expression is whatever the next send passes in; next() is send(None).

OutputWhat does this print, and in what order?

import asyncio

async def job(name, n):
    for i in range(n):
        print(name, i)
        await asyncio.sleep(0)
    return name

async def main():
    t = asyncio.create_task(job("A", 2))
    print("created")
    r = await job("B", 2)
    print("B done", await t)

asyncio.run(main())
Answer
created
B 0
A 0
B 1
A 1
B done A

Answer. created, B 0, A 0, B 1, A 1, B done A. Creating the task schedules it but does not run it; it first runs when main reaches its first await (inside job("B")). After that, every sleep(0) lets the other task take a step.

Why. asyncio.sleep(0) is the idiomatic "yield to the loop". Awaiting a coroutine directly (await job("B", 2)) runs it in the current task, without creating a new one.

Find the bugThis async crawler takes as long as the sum of the delays instead of the maximum. Why?

import asyncio, time

async def fetch(delay):
    await asyncio.sleep(delay)
    return delay

async def crawl_slow(delays):
    return [await fetch(d) for d in delays]

async def crawl(delays):
    return await asyncio.gather(*(fetch(d) for d in delays))

for fn in (crawl_slow, crawl):
    t0 = time.perf_counter()
    asyncio.run(fn([0.1, 0.1, 0.1]))
    print(fn.__name__, round(time.perf_counter() - t0, 1))
Answer
crawl_slow 0.3
crawl 0.1

Answer. await inside the list comprehension awaits each fetch before starting the next, so they run one after another. Start them all first, with gather or create_task, and then await the results.

Why. await means "wait for this to finish". Concurrency comes from having several tasks in flight, not from writing async.

Find the bugA generator that reads a file leaks the file handle when the consumer stops early. Fix it.

import os, tempfile

path = os.path.join(tempfile.mkdtemp(), "data.txt")
with open(path, "w") as f:
    f.write("a\nb\nc\n")

opened = []

def lines(p):
    with open(p) as fh:
        opened.append(fh)
        for line in fh:
            yield line.strip()

for line in lines(path):
    if line == "a":
        break
gen = lines(path)
next(gen)
gen.close()
print([fh.closed for fh in opened])
Answer
[True, True]

Answer. With a with block inside the generator (as above), the file closes when the generator is closed, either explicitly with close() or when it is garbage collected after the break. The buggy version used fh = open(p) without with or try/finally, so an early break left the file open until interpreter exit.

Why. close() raises GeneratorExit at the paused yield, which runs the with block's exit. In CPython the abandoned generator in the for loop is collected immediately; on other implementations call close() or use contextlib.closing.

Write the code

Coding

CodeWrite chunked(iterable, n) that yields lists of up to n items from any iterable, lazily, including infinite iterators, then use it on itertools.count().

Estimated difficulty: about 1300 (Codeforces-style)

Answer
from itertools import islice, count

def chunked(iterable, n):
    if n < 1:
        raise ValueError("n must be >= 1")
    it = iter(iterable)
    while chunk := list(islice(it, n)):
        yield chunk

print(list(chunked(range(7), 3)))
inf = chunked(count(), 4)
print(next(inf), next(inf))
[[0, 1, 2], [3, 4, 5], [6]]
[0, 1, 2, 3] [4, 5, 6, 7]

Calling iter() once is essential: islice on a list would restart from the beginning every time. Python 3.12 adds itertools.batched, which does the same and yields tuples.

CodeWrite a minimal round-robin scheduler for generator-based tasks: each task is a generator that yields to give up control. run(tasks) interleaves them until all finish and returns their return values.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
from collections import deque

def run(tasks):
    queue = deque(enumerate(tasks))
    results = [None] * len(tasks)
    while queue:
        i, task = queue.popleft()
        try:
            next(task)
        except StopIteration as stop:
            results[i] = stop.value
        else:
            queue.append((i, task))
    return results

def worker(name, steps, log):
    for s in range(steps):
        log.append(f"{name}{s}")
        yield
    return f"{name} done"

log = []
print(run([worker("A", 3, log), worker("B", 1, log), worker("C", 2, log)]))
print(log)
['A done', 'B done', 'C done']
['A0', 'B0', 'C0', 'A1', 'C1', 'A2']

This is the core of every cooperative scheduler, including asyncio: a queue of runnable tasks, each resumed until it voluntarily suspends. asyncio adds futures, timers and a selector to decide when a suspended task is ready again.

CodeWrite a tiny event loop that supports await sleep(seconds) for native async def coroutines, using a heap of wake-up times and the types.coroutine trick to yield the wake-up time to the loop. Run three coroutines with different sleeps and record the order they finish.

Estimated difficulty: about 1600 (Codeforces-style)

Answer
import heapq, time, types, itertools

@types.coroutine
def sleep(seconds):
    yield seconds

def run(*coros):
    clock = 0.0                      # simulated time, so the demo is instant
    seq = itertools.count()
    ready = [(0.0, next(seq), c) for c in coros]
    heapq.heapify(ready)
    finished = []
    while ready:
        when, _, coro = heapq.heappop(ready)
        clock = when
        try:
            delay = coro.send(None)
        except StopIteration as stop:
            finished.append((round(clock, 2), stop.value))
        else:
            heapq.heappush(ready, (clock + delay, next(seq), coro))
    return finished

async def task(name, *sleeps):
    for s in sleeps:
        await sleep(s)
    return name

print(run(task("slow", 0.5), task("fast", 0.1, 0.1), task("medium", 0.3)))
[(0.2, 'fast'), (0.3, 'medium'), (0.5, 'slow')]

The sequence counter breaks ties so the heap never compares two coroutine objects. A real loop would also sleep in selector.select(timeout) until the next wake-up time instead of jumping the clock.

CodeWrite an async producer/consumer pipeline with asyncio.Queue(maxsize=2): one producer puts 6 items, two consumers process them, and the program shuts down cleanly when all items are done using queue.join() and task cancellation.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
import asyncio

async def producer(q):
    for i in range(6):
        await q.put(i)          # waits when the queue is full (back-pressure)

async def consumer(name, q, out):
    while True:
        item = await q.get()
        await asyncio.sleep(0.01)
        out.append((name, item))
        q.task_done()

async def main():
    q = asyncio.Queue(maxsize=2)
    out = []
    workers = [asyncio.create_task(consumer(n, q, out)) for n in ("c1", "c2")]
    await producer(q)
    await q.join()               # every item has been marked task_done
    for w in workers:
        w.cancel()
    await asyncio.gather(*workers, return_exceptions=True)
    return sorted(item for _, item in out), len({n for n, _ in out})

print(asyncio.run(main()))
([0, 1, 2, 3, 4, 5], 2)

maxsize gives back-pressure: a fast producer is suspended at put instead of filling memory. Cancelling raises CancelledError at the consumers' await q.get(), and return_exceptions=True collects those instead of raising.

Cheat sheet

Theory
  • Iterable: __iter__ → iterator. Iterator: __next__ + __iter__ returning self; single pass. for = iter() once + next() until StopIteration.
  • Generator function call runs nothing; returns a generator with its own frame. States: CREATED, RUNNING, SUSPENDED, CLOSED.
  • return v in a generator → StopIteration(v); PEP 479 turns leaked StopIteration into RuntimeError.
  • send(v) sets the value of the paused yield (prime with next), throw(exc) raises there, close() raises GeneratorExit (runs finally).
  • Genexps evaluate their first iterable eagerly. Generators save memory; pipelines compute only what is consumed.
  • yield from sub: forwards values, send, throw, close; evaluates to sub's return value.
  • async def → coroutine object; nothing runs until awaited or wrapped in a task; "never awaited" warning.
  • asyncio: one thread; ready queue + timer heap + selector; tasks step via coro.send(None) until awaiting a pending future.
  • Concurrency needs several tasks (gather, create_task, TaskGroup); await in a loop is sequential.
  • Never block the loop: use await asyncio.sleep, async libraries, asyncio.to_thread / run_in_executor.

Part E · Control flow and concurrency · Chapter 11

Concurrency and the GIL

CPython's Global Interpreter Lock lets only one thread execute Python bytecode at a time, which simplifies the interpreter and makes single-threaded code fast, but stops pure-Python threads from using more than one core. This chapter explains what the GIL protects and what it does not, when threads switch, why counter += 1 is still a race, when threads do run in parallel, how to choose between threads, processes and asyncio, and what changes with the free-threaded build.

TheoryDrillsCoding· about 3.5 hours· prerequisites: Chapters 3, 4 and 10

What the GIL is

Theory

The GIL is a single lock per interpreter. A thread must hold it to run Python bytecode or touch Python objects through the C API. It exists because CPython's internals, starting with reference counting, are not thread-safe: two threads doing Py_INCREF on the same object at once could lose an increment and free a live object. Rather than locking every object, CPython locks the whole interpreter.

Consequences:

  • At any instant, at most one thread per interpreter is executing Python code.
  • Single-threaded code pays no locking cost, and C extensions are easy to write correctly.
  • Threads still help for I/O-bound work, because a thread releases the GIL while it waits in a system call.
  • Threads do not speed up CPU-bound pure-Python code.
import threading, time

def spin(n):
    total = 0
    for i in range(n):
        total += i

N = 3_000_000
t0 = time.perf_counter()
spin(N); spin(N)
sequential = time.perf_counter() - t0

t0 = time.perf_counter()
threads = [threading.Thread(target=spin, args=(N,)) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
threaded = time.perf_counter() - t0
print(f"sequential {sequential:.2f}s, two threads {threaded:.2f}s")
print("threads gave a 1.5x speed-up:", threaded * 1.5 < sequential)
sequential 0.23s, two threads 0.25s
threads gave a 1.5x speed-up: False

On a 16-core machine the two threads take as long as running the work back to back, because they take turns holding the GIL.

When threads switch

Theory

A thread that holds the GIL keeps it until one of these happens:

  1. It blocks: time.sleep, reading a socket or file, waiting on a lock, select. Blocking calls release the GIL around the system call.
  2. A C extension explicitly releases it (Py_BEGIN_ALLOW_THREADS) for a long computation: hashlib, zlib, NumPy operations and many database drivers do this.
  3. Another thread has been waiting longer than the switch interval (5 ms by default). The waiting thread sets a "drop request" flag; the running thread checks the interpreter's "eval breaker" between bytecode instructions (at backward jumps and calls), sees the flag, and hands the GIL over.
import sys
print(sys.getswitchinterval())
sys.setswitchinterval(0.001)
print(sys.getswitchinterval())
0.005
0.001

So a thread switch can happen between any two bytecode instructions of Python code, but never in the middle of one instruction.

Why counter += 1 is a race

counter += 1 on a global is several instructions: load the global, load the constant, add, store the global. If the thread is switched out after the load and before the store, another thread's update is overwritten. Putting a blocking call in the gap makes the lost updates happen every time:

import dis, threading, time

def bump():
    global counter
    counter += 1

print([i.opname for i in dis.get_instructions(bump)][1:])

counter = 0
def unsafe(n):
    global counter
    for _ in range(n):
        value = counter
        time.sleep(0)          # gives up the GIL between read and write
        counter = value + 1

lock = threading.Lock()
safe_counter = 0
def safe(n):
    global safe_counter
    for _ in range(n):
        with lock:
            value = safe_counter
            time.sleep(0)
            safe_counter = value + 1

for target in (unsafe, safe):
    threads = [threading.Thread(target=target, args=(200,)) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()
print("unsafe lost updates:", counter < 800, "| locked:", safe_counter)
['LOAD_GLOBAL', 'LOAD_CONST', 'BINARY_OP', 'STORE_GLOBAL', 'RETURN_CONST']
unsafe lost updates: True | locked: 800

Without the sleep(0), the race still exists; it just shows up rarely, whenever the 5 ms switch lands between the load and the store. Rare races are worse than frequent ones, because they pass tests.

Interview trap

"The GIL makes my code thread-safe" is false. The GIL makes individual bytecode instructions atomic with respect to each other. Any read-modify-write sequence that spans several instructions, such as x += 1, if key not in d: d[key] = …, or lst[i] = lst[i] + 1, needs a lock.

What is effectively atomic

Operations that run as a single C call on a built-in type cannot be interrupted by another Python thread: list.append, list.pop, dict[key] = value, dict.get, set.add, deque.append and popleft. That is why queue.Queue and collections.deque are safe to share between threads. This is a CPython implementation detail, not a language guarantee; the free-threaded build keeps these operations safe with per-object locks.

import threading

items = []
def add(n):
    for i in range(n):
        items.append(i)

threads = [threading.Thread(target=add, args=(10_000,)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(len(items))
80000

No appends are lost, even without a lock.

When threads do run in parallel

Theory

Threads overlap whenever the GIL is released:

  • Waiting on I/O. A web scraper with 50 threads waits on 50 sockets at once.
  • C code that releases the GIL. Hashing, compression, NumPy, image processing and time.sleep itself.
import hashlib, threading, time

data = b"x" * 50_000_000

def digest():
    hashlib.sha256(data).digest()

t0 = time.perf_counter()
for _ in range(4):
    digest()
sequential = time.perf_counter() - t0

t0 = time.perf_counter()
threads = [threading.Thread(target=digest) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
threaded = time.perf_counter() - t0
print(f"sequential {sequential:.2f}s, 4 threads {threaded:.2f}s")
print("real parallel speed-up:", threaded * 2 < sequential)
sequential 0.11s, 4 threads 0.05s
real parallel speed-up: True

hashlib releases the GIL while hashing large buffers, so four threads use four cores.

Processes

For CPU-bound pure-Python work, use processes: each has its own interpreter, its own GIL and its own memory. multiprocessing and concurrent.futures.ProcessPoolExecutor send work and results between processes by pickling them, which costs time and requires picklable functions and arguments (no lambdas or local functions with the spawn start method).

import time
from concurrent.futures import ProcessPoolExecutor

def spin(n):
    total = 0
    for i in range(n):
        total += i
    return total

if __name__ == "__main__":
    N = 3_000_000
    t0 = time.perf_counter()
    expected = [spin(N) for _ in range(4)]
    sequential = time.perf_counter() - t0
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(spin, [N] * 4))
    parallel = time.perf_counter() - t0
    print(results == expected, f"sequential {sequential:.2f}s, 4 processes {parallel:.2f}s")
True sequential 0.69s, 4 processes 0.24s

The if __name__ == "__main__": guard is required with the spawn and forkserver start methods (the default on macOS and Windows, and on Linux from 3.14), because each worker re-imports the main module.

WorkloadUseWhy
Many slow network or disk operationsasyncio, or threadsWaiting releases the GIL; asyncio scales to thousands of connections on one thread
Blocking libraries without async supportThreadPoolExecutorSimple, shares memory
CPU-bound pure PythonProcessPoolExecutor, multiprocessingOne GIL per process
CPU-bound NumPy / hashing / compressionThreadsThe C code releases the GIL

Synchronisation tools

Theory
  • threading.Lock: mutual exclusion; not re-entrant (acquiring it twice in the same thread deadlocks).
  • threading.RLock: re-entrant; the owner can acquire it again, and must release it the same number of times.
  • threading.Condition: wait for a predicate under a lock (wait_for), with notify / notify_all.
  • threading.Semaphore(n): allow at most n holders; BoundedSemaphore catches extra releases.
  • threading.Event: a flag threads can wait on.
  • queue.Queue: a thread-safe FIFO with blocking get/put, maxsize for back-pressure, and task_done/join.
import threading

lock = threading.Lock()
rlock = threading.RLock()

def nested(l):
    with l:
        return l.acquire(timeout=0.1)

print("Lock re-acquired:", nested(lock))
print("RLock re-acquired:", nested(rlock))
rlock.release()
Lock re-acquired: False
RLock re-acquired: True

A plain Lock simply blocks (here until the timeout) when the holder tries to take it again; with no timeout that is a self-deadlock.

Deadlock

Two threads that take two locks in opposite orders can each hold one and wait forever for the other. The standard prevention is a global lock order: always acquire locks in the same order, for example sorted by id. Timeouts (lock.acquire(timeout=…)) detect it but do not prevent it.

Beyond the GIL

Theory

Per-interpreter GIL (3.12)

PEP 684 gave each sub-interpreter its own GIL. Sub-interpreters in one process can run Python code in parallel, but they share no objects; they communicate by copying data, much like processes but with cheaper start-up. 3.14 exposes them in the standard library as concurrent.interpreters (PEP 734).

Free-threaded CPython (3.13+)

PEP 703 added an optional build without the GIL (python3.13t), experimental in 3.13 and officially supported (still optional) from 3.14. Making it safe required:

  • Biased reference counting: each object has a fast, non-atomic count for its owning thread and an atomic count for others.
  • Immortal objects (Chapter 1) and deferred reference counting for objects that are shared heavily, such as functions and modules.
  • Per-object locks inside built-in containers, so list.append and dict updates stay safe.
  • mimalloc as the allocator, which supports the thread-safe lookups the new design needs.

Single-threaded code runs somewhat slower on the free-threaded build, and C extensions must be rebuilt and declare that they are safe without the GIL. The rule from this chapter does not change: compound operations such as counter += 1 still need a lock. With the GIL gone they fail far more often.

Key idea

In an interview, separate the three ideas: the GIL (one bytecode-running thread per interpreter in the default build), thread safety (your read-modify-write sequences, which always need locks), and parallelism (processes, GIL-releasing C code, sub-interpreters or the free-threaded build).

Concept and tricky questions

Drills

ConceptWhat does the GIL protect, and why did CPython choose one global lock?

Answer

Answer. It protects the interpreter's internal state, most importantly reference counts and the internals of built-in objects, from concurrent modification. One lock makes single-threaded code fast (no per-object locking) and C extensions simpler to write.

Why. Fine-grained locking was tried (the "free-threading" patch of 1999) and made single-threaded code much slower; PEP 703 succeeded only with biased refcounting and other new techniques.

ConceptWhen does a thread release the GIL?

Answer

Answer. When it makes a blocking system call (I/O, sleep, lock waits), when a C extension releases it explicitly around long computations, and when another thread has waited longer than the switch interval (5 ms), detected at the eval breaker between bytecodes.

Why. That is why I/O-bound threading works well and CPU-bound pure-Python threading does not.

Trickycounter += 1 is a single line. Why is it not thread-safe in CPython?

Answer

Answer. It compiles to several bytecodes (load, add, store). A thread switch can occur between the load and the store, and the other thread's update is then lost.

Why. The GIL only guarantees that each bytecode runs without interruption. Protect the whole sequence with a Lock, or use a thread-safe structure.

ConceptYour service computes SHA-256 hashes of large uploads in a thread pool. Will more threads help on a multi-core machine?

Answer

Answer. Yes. hashlib releases the GIL while hashing data larger than a small threshold, so several threads hash in parallel on different cores.

Why. "Threads don't help with CPU work" applies to pure-Python code. Check whether the C library you call releases the GIL.

ConceptWhy must multiprocessing code use the if __name__ == "__main__": guard on macOS and Windows?

Answer

Answer. Those platforms start workers with spawn, which launches a fresh interpreter that imports the main module to find the target function. Without the guard, each child would run the pool-creating code again and spawn its own children, recursively.

Why. Linux used fork by default, which copies the parent instead of re-importing, but 3.14 switches the Linux default to forkserver because fork is unsafe when the parent has threads. Always write the guard.

TrickyIs dict[key] = value from many threads safe in CPython without a lock? Is if key not in d: d[key] = make() safe?

Answer

Answer. The single assignment is safe because it is one C-level operation under the GIL. The check-then-set is not: two threads can both see the key missing and both call make(), and one result overwrites the other.

Why. Use d.setdefault(key, value) (a single call) when the value is cheap, or a lock around the check and the creation.

ConceptWhat changes, and what does not, in the free-threaded build?

Answer

Answer. Python threads can run bytecode in parallel on several cores. Built-in containers keep their individual operations safe using per-object locks, and refcounting uses biased counting. What does not change: compound operations still need locks, and extensions must be built for it.

Why. Code that was "accidentally safe" under the GIL (rare race windows) will fail much more often without it.

Predict the output and find the bug

Drills

OutputWhat does this print?

import threading, time

done = []
def work():
    time.sleep(0.05)
    done.append("worker")

t = threading.Thread(target=work)
t.start()
print("main continues", done)
t.join()
print("after join", done)
Answer
main continues []
after join ['worker']

Answer. main continues [], then after join ['worker']. start() returns immediately; join() waits for the thread to finish.

Why. A non-daemon thread keeps the process alive until it ends; a daemon thread (daemon=True) is killed abruptly when the main thread exits.

OutputWhat does this print?

from concurrent.futures import ThreadPoolExecutor

def risky(x):
    if x == 2:
        raise ValueError(f"bad {x}")
    return x * 10

with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(risky, i) for i in range(4)]
    results = []
    for f in futures:
        try:
            results.append(f.result())
        except ValueError as e:
            results.append(str(e))
print(results)
Answer
[0, 10, 'bad 2', 30]

Answer. [0, 10, 'bad 2', 30]. An exception raised in a worker is stored in its future and re-raised when you call result().

Why. If you never call result() (or check exception()), errors in worker threads vanish silently. pool.map re-raises the first error while iterating.

OutputWhat does this print?

import threading

items = []
cond = threading.Condition()

def consumer(out):
    with cond:
        cond.wait_for(lambda: len(items) >= 3)
        out.append(list(items))

out = []
c = threading.Thread(target=consumer, args=(out,))
c.start()
for i in range(5):
    with cond:
        items.append(i)
        cond.notify_all()
c.join()
print(len(out[0]) >= 3, out[0][:3])
Answer
True [0, 1, 2]

Answer. True [0, 1, 2]. The consumer sleeps until the predicate holds; it wakes after at least three items are added (possibly more, depending on scheduling).

Why. wait_for re-checks the predicate in a loop, which also handles spurious wake-ups. Always wait for a condition, never for a single notification.

Find the bugTwo threads transfer money between accounts and the program sometimes hangs. Find the bug and fix it.

import threading

class Account:
    def __init__(self, name, balance):
        self.name, self.balance = name, balance
        self.lock = threading.Lock()

def transfer(src, dst, amount):
    first, second = sorted((src, dst), key=lambda a: a.name)
    with first.lock:
        with second.lock:
            src.balance -= amount
            dst.balance += amount

a, b = Account("a", 100), Account("b", 100)
threads = [threading.Thread(target=transfer, args=(a, b, 1)) for _ in range(50)]
threads += [threading.Thread(target=transfer, args=(b, a, 1)) for _ in range(50)]
for t in threads: t.start()
for t in threads: t.join()
print(a.balance, b.balance)
Answer
100 100

Answer. The original locked src.lock then dst.lock. A transfer a→b and one b→a could each take their first lock and wait forever for the second. The fix above always locks in a global order (sorted by name), so no cycle of waiting can form.

Why. Deadlock needs a cycle in the "waits for" graph; a total lock order makes cycles impossible.

Find the bugA lazily created shared client is sometimes created twice under load. Fix it.

import threading, time

_client = None
_created = 0
_lock = threading.Lock()

def get_client():
    global _client, _created
    if _client is None:
        with _lock:
            if _client is None:
                time.sleep(0.01)
                _created += 1
                _client = object()
    return _client

threads = [threading.Thread(target=get_client) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("clients created:", _created)
Answer
clients created: 1

Answer. The original checked if _client is None and created the client without a lock, so several threads passed the check during the slow creation. The fix takes a lock and checks again inside it (double-checked locking), so exactly one client is created.

Why. The first, unlocked check avoids taking the lock on every call once the client exists. functools.cache on a zero-argument function is not a safe alternative: it can call the function more than once under concurrency.

Write the code

Coding

CodeWrite a thread-safe BoundedBuffer(capacity) with blocking put and get, using one lock and two Conditions (not queue.Queue). Test it with two producers and two consumers.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
import threading
from collections import deque

class BoundedBuffer:
    def __init__(self, capacity):
        self.items = deque()
        self.capacity = capacity
        lock = threading.Lock()
        self.not_full = threading.Condition(lock)
        self.not_empty = threading.Condition(lock)

    def put(self, item):
        with self.not_full:
            self.not_full.wait_for(lambda: len(self.items) < self.capacity)
            self.items.append(item)
            self.not_empty.notify()

    def get(self):
        with self.not_empty:
            self.not_empty.wait_for(lambda: self.items)
            item = self.items.popleft()
            self.not_full.notify()
            return item

buf = BoundedBuffer(3)
received = []
recv_lock = threading.Lock()

def producer(start):
    for i in range(start, start + 50):
        buf.put(i)

def consumer():
    for _ in range(50):
        x = buf.get()
        with recv_lock:
            received.append(x)

threads = [threading.Thread(target=producer, args=(s,)) for s in (0, 100)]
threads += [threading.Thread(target=consumer) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(len(received), sorted(received) == list(range(50)) + list(range(100, 150)))
100 True

Both conditions share one lock, so a single critical section protects the deque, and each side wakes only the threads that can make progress.

CodeWrite a RateLimiter(rate, per) usable from many threads: acquire() blocks until a call is allowed, allowing at most rate calls per per seconds (a token bucket). Show that 10 calls at 50 per second take about 0.2 seconds after the initial burst.

Estimated difficulty: about 1300 (Codeforces-style)

Answer
import threading, time

class RateLimiter:
    def __init__(self, rate, per):
        self.capacity = rate
        self.tokens = float(rate)
        self.fill_rate = rate / per
        self.updated = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self):
        while True:
            with self.lock:
                now = time.monotonic()
                self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.fill_rate)
                self.updated = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                wait = (1 - self.tokens) / self.fill_rate
            time.sleep(wait)

limiter = RateLimiter(rate=5, per=0.1)
t0 = time.perf_counter()
threads = [threading.Thread(target=limiter.acquire) for _ in range(15)]
for t in threads: t.start()
for t in threads: t.join()
print(f"15 calls with a burst of 5 at 50/s took {time.perf_counter() - t0:.2f}s")
15 calls with a burst of 5 at 50/s took 0.20s

The first 5 calls use the full bucket; the other 10 are released at 50 per second, about 0.2 s. Sleeping outside the lock lets other threads refill and check.

CodeWrite parallel_map(fn, items, workers) that uses processes for CPU-bound work, keeps results in input order, and processes items in chunks to reduce pickling overhead. Compare one call with chunksize=1 and one with a larger chunk size.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
import time
from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

def parallel_map(fn, items, workers=4, chunksize=None):
    items = list(items)
    if chunksize is None:
        chunksize = max(1, len(items) // (workers * 4))
    with ProcessPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(fn, items, chunksize=chunksize))

if __name__ == "__main__":
    data = range(4_000)
    for cs in (1, None):
        t0 = time.perf_counter()
        out = parallel_map(square, data, chunksize=cs)
        print(f"chunksize={cs}: {time.perf_counter() - t0:.2f}s, correct={out == [x * x for x in data]}")
chunksize=1: 0.54s, correct=True
chunksize=None: 0.02s, correct=True

With chunksize=1, each tiny task pays a full round trip of pickling and inter-process messaging; chunking sends batches and is several times faster. For work this small, a plain loop in one process beats both, which is the real lesson: parallelism only pays off when each task is much more expensive than sending it.

CodeUse a ThreadPoolExecutor to "download" 20 URLs (simulated with time.sleep(0.05)), with at most 5 in flight, collecting results as they complete with as_completed, and print the total time.

Estimated difficulty: about 1200 (Codeforces-style)

Answer
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

def download(url):
    time.sleep(0.05)
    return url, len(url)

urls = [f"https://example.com/page/{i}" for i in range(20)]
t0 = time.perf_counter()
sizes = {}
with ThreadPoolExecutor(max_workers=5) as pool:
    futures = {pool.submit(download, u): u for u in urls}
    for fut in as_completed(futures):
        url, size = fut.result()
        sizes[url] = size
print(len(sizes), f"{time.perf_counter() - t0:.2f}s for 20 x 50 ms with 5 workers")
20 0.20s for 20 x 50 ms with 5 workers

20 waits of 50 ms in 4 rounds of 5 take about 0.2 s instead of 1 s: sleep, like a socket read, releases the GIL. as_completed yields futures in completion order, so slow downloads do not hold up processing of fast ones.

Cheat sheet

Theory
  • GIL: one lock per interpreter; one thread runs bytecode at a time; protects refcounts and object internals.
  • Released on blocking I/O, sleep, lock waits, and by C code (hashlib, zlib, NumPy). Forced switch after sys.getswitchinterval() = 5 ms, checked between bytecodes.
  • Single C-level operations on built-ins (append, dict[k] = v, deque.popleft) are atomic; multi-bytecode sequences (x += 1, check-then-set) are races.
  • I/O-bound: threads or asyncio. CPU-bound pure Python: processes. CPU-bound in GIL-releasing C: threads.
  • Processes pickle arguments and results; guard with if __name__ == "__main__":; use chunksize for many small tasks.
  • Lock (not re-entrant), RLock, Condition.wait_for, Semaphore, Event, queue.Queue. Prevent deadlock with a global lock order.
  • Exceptions in executor tasks surface only via future.result().
  • 3.12: per-interpreter GIL (PEP 684). 3.13: optional free-threaded build (PEP 703), officially supported from 3.14: biased refcounting, per-object locks, mimalloc. Locks are still needed.

Part F · Programs · Chapter 12

Imports, modules and packages

import x checks a cache, asks a chain of finders for a spec, creates a module object, runs the module's code inside it and binds a name, and each of those steps explains a familiar bug. This chapter covers sys.modules, finders and loaders, how sys.path is built, packages and relative imports, why __main__ can be imported twice, how circular imports fail, and how .pyc files are cached and invalidated.

TheoryDrillsCoding· about 3 hours· prerequisites: Chapters 2–3

What import does

Theory

import mod performs these steps:

  1. Check the cache. If "mod" is in sys.modules, use that module object. Nothing else happens.
  2. Find a spec. Ask each finder in sys.meta_path in turn for a ModuleSpec (name, loader, origin). The defaults are BuiltinImporter (modules compiled into the interpreter, such as sys), FrozenImporter (frozen modules such as parts of importlib and, since 3.11, os and other start-up modules) and PathFinder (searches the directories on sys.path).
  3. Create the module from the spec and insert it into sys.modules before running its code.
  4. Execute the module's code in the new module's __dict__. This is where every top-level statement in the file runs.
  5. Bind the name mod in the importing namespace.
import sys, importlib.util

print("json" in sys.modules)
spec = importlib.util.find_spec("json")
print(spec.name, type(spec.loader).__name__, spec.origin.endswith("json/__init__.py"))
import json
print("json" in sys.modules, sys.modules["json"] is json)
print([f.__name__ for f in sys.meta_path if f.__module__.startswith("_frozen_importlib")])
False
json SourceFileLoader True
True True
['BuiltinImporter', 'FrozenImporter', 'PathFinder']

Because step 1 returns the cached module, a module's top-level code runs once per process, no matter how many times or from how many places it is imported. That makes a module a natural singleton, and it is also why editing a file and re-importing it in a running program changes nothing.

import sys, os, tempfile, importlib

pkg_dir = tempfile.mkdtemp()
with open(os.path.join(pkg_dir, "settings.py"), "w") as f:
    f.write("print('settings loaded')\nvalue = 1\n")
sys.path.insert(0, pkg_dir)

import settings
import settings
from settings import value
print(value, settings is sys.modules["settings"])

with open(os.path.join(pkg_dir, "settings.py"), "w") as f:
    f.write("print('settings reloaded')\nvalue = 2\n")
importlib.reload(settings)
print(settings.value, value)
settings loaded
1 True
settings reloaded
2 1

importlib.reload re-executes the code in the same module object, so settings.value changes. But value, bound earlier by from settings import value, still refers to the old object, which is why reloading is unreliable in real programs.

Key idea

from mod import name copies a reference at import time. If mod later rebinds name, your copy does not change. To see the current value, keep a reference to the module and read mod.name.

sys.path and where modules come from

Theory

PathFinder searches the directories in sys.path, in order, and takes the first match. At start-up sys.path is built from:

  1. The directory of the script being run (or the current directory for -c, -m and the REPL). This entry is the reason a file called random.py next to your script breaks import random everywhere in that program.
  2. PYTHONPATH, if set.
  3. The standard library directories.
  4. site-packages directories, added by the site module, plus any paths listed in .pth files there.

python -I (isolated mode) skips PYTHONPATH, the user site directory and the script's directory, which is why this guide runs every example with it.

import sys, os, tempfile, subprocess

d = tempfile.mkdtemp()
with open(os.path.join(d, "random.py"), "w") as f:
    f.write("print('this is my random.py, not the stdlib')\n")
with open(os.path.join(d, "app.py"), "w") as f:
    f.write("import random\nprint(hasattr(random, 'randint'))\n")
out = subprocess.run([sys.executable, "app.py"], cwd=d, capture_output=True, text=True).stdout
print(out.strip())
out = subprocess.run([sys.executable, "-I", "app.py"], cwd=d, capture_output=True, text=True).stdout
print(out.strip())
this is my random.py, not the stdlib
False
True

-I fixes the demo, but the real fix is never to name your files after standard-library modules.

Packages

Theory

A package is a module that can contain other modules. A directory with an __init__.py is a regular package: importing it runs __init__.py, and the module gets a __path__ attribute listing where to look for submodules. A directory without __init__.py on sys.path is a namespace package (PEP 420), which can be split across several directories and has no initialisation code.

Importing a.b.c imports a, then a.b, then a.b.c, binding each as an attribute of its parent. Relative imports (from . import util, from ..core import x) are resolved against the current module's __package__, so they only work inside a package, not in a script run directly.

import sys, os, tempfile

root = tempfile.mkdtemp()
os.makedirs(os.path.join(root, "shop", "billing"))
files = {
    "shop/__init__.py": "print('init shop')\nVERSION = '1.0'\n",
    "shop/billing/__init__.py": "",
    "shop/billing/invoice.py": "from ..utils import money\ndef total(xs):\n    return money(sum(xs))\n",
    "shop/utils.py": "def money(x):\n    return f'${x:.2f}'\n",
}
for rel, src in files.items():
    with open(os.path.join(root, rel), "w") as f:
        f.write(src)
sys.path.insert(0, root)

from shop.billing.invoice import total
import shop
print(total([1.5, 2.25]), shop.VERSION, "shop.billing" in sys.modules)
print(shop.billing.invoice.__package__, shop.__path__[0].endswith("shop"))
init shop
$3.75 1.0 True
shop.billing True

Importing shop.billing.invoice ran shop/__init__.py first, and import shop afterwards found it in the cache.

__name__ and "__main__"

When you run python app.py, the file is executed as the module named __main__, not as app. That is what the if __name__ == "__main__": idiom tests. It also causes a classic bug: if some other module does import app, Python does not find app in sys.modules (it is registered as __main__), so it imports the file again as a second module with its own copies of every global and class:

import sys, os, tempfile, subprocess

d = tempfile.mkdtemp()
with open(os.path.join(d, "app.py"), "w") as f:
    f.write(
        "import helper\n"
        "registry = []\n"
        "print('app runs as', __name__)\n"
        "if __name__ == '__main__':\n"
        "    helper.register()\n"
        "    print('main sees', registry)\n"
    )
with open(os.path.join(d, "helper.py"), "w") as f:
    f.write("def register():\n    import app\n    app.registry.append('x')\n    print('helper sees', app.registry)\n")
result = subprocess.run([sys.executable, "-s", "app.py"], cwd=d, capture_output=True, text=True)
print(result.stdout + result.stderr)
app runs as __main__
app runs as app
helper sees ['x']
main sees []

The script body runs twice (as __main__ and as app), and the helper appends to a different registry list from the one __main__ prints. Keep the entry point thin and put shared state in modules that are only ever imported.

Circular imports

Theory

Step 3 above inserts a module into sys.modules before its code runs. If module a imports b and b imports a, the inner import a finds the partially initialised a in the cache and returns it. Whatever a defines after its import b line does not exist yet:

import sys, os, tempfile

d = tempfile.mkdtemp()
with open(os.path.join(d, "orders.py"), "w") as f:
    f.write("import customers\nclass Order:\n    pass\ndef describe():\n    return customers.Customer.__name__\n")
with open(os.path.join(d, "customers.py"), "w") as f:
    f.write("from orders import Order\nclass Customer:\n    pass\n")
sys.path.insert(0, d)
try:
    import orders
except ImportError as e:
    print("ImportError:", e)

for name in ("orders", "customers"):
    sys.modules.pop(name, None)
with open(os.path.join(d, "customers.py"), "w") as f:
    f.write("import orders\nclass Customer:\n    def first_order(self):\n        return orders.Order()\n")
import orders
print(orders.describe(), type(orders.customers.Customer().first_order()).__name__)
ImportError: cannot import name 'Order' from partially initialized module 'orders' (most likely due to a circular import) (/tmp/tmpXXXX/orders.py)
Customer Order

from orders import Order fails because orders exists but has not yet reached the class Order line. Switching to import orders and reading orders.Order inside a function defers the attribute lookup until both modules have finished loading.

Tip

Ways out of a circular import, from best to worst: move the shared code into a third module both can import; import the module (not names from it) and access attributes at call time; move the import inside the function that needs it; for type hints only, use from __future__ import annotations and if TYPE_CHECKING: imports.

Bytecode caching

Theory

Compiling a module is the slowest part of importing it, so SourceFileLoader caches the code object in __pycache__/<name>.<cache_tag>.pyc, for example settings.cpython-312.pyc. The file is a 16-byte header followed by the code object serialised with marshal:

  • 4 bytes: the magic number of the bytecode format, which changes in nearly every minor Python version;
  • 4 bytes: flags, saying whether validation uses the source's timestamp or its hash (PEP 552);
  • 8 bytes: the source's modification time and size, or a 64-bit hash of the source.

On import, the loader compares the header against the source file and recompiles if they disagree.

import sys, os, tempfile, importlib, importlib.util, marshal

sys.dont_write_bytecode = False          # this guide's runner disables pyc writing
d = tempfile.mkdtemp()
with open(os.path.join(d, "cached_mod.py"), "w") as f:
    f.write("ANSWER = 42\n")
sys.path.insert(0, d)
import cached_mod

pyc = importlib.util.cache_from_source(os.path.join(d, "cached_mod.py"))
print(os.path.relpath(pyc, d))
with open(pyc, "rb") as f:
    data = f.read()
print(data[:4] == importlib.util.MAGIC_NUMBER, int.from_bytes(data[4:8], "little"))
code = marshal.loads(data[16:])
print(type(code).__name__, code.co_names, code.co_consts)
__pycache__/cached_mod.cpython-312.pyc
True 0
code ('ANSWER',) (42, None)

Only imported modules are cached; the script you run directly is compiled every time, which is another reason to keep the entry script small. python -B or PYTHONDONTWRITEBYTECODE=1 disables writing .pyc files, and python -m compileall pre-compiles a tree (useful for read-only deployments).

Hooks: module __getattr__ and custom finders

Theory

Since 3.7 (PEP 562) a module can define __getattr__(name) (and __dir__), called when an attribute is not found. It is the standard way to deprecate names or load heavy submodules lazily:

import sys, types, warnings

mod = types.ModuleType("compat")
exec(
    "import warnings\n"
    "new_name = 'value'\n"
    "def __getattr__(name):\n"
    "    if name == 'old_name':\n"
    "        warnings.warn('old_name is deprecated', DeprecationWarning, stacklevel=2)\n"
    "        return new_name\n"
    "    raise AttributeError(f'module {__name__!r} has no attribute {name!r}')\n",
    mod.__dict__,
)
sys.modules["compat"] = mod
import compat
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    print(compat.old_name, compat.new_name)
print(caught[0].category.__name__, str(caught[0].message))
value value
DeprecationWarning old_name is deprecated

To change where modules come from, add an object with a find_spec method to sys.meta_path. This is how zip imports, editable installs, frozen applications and many test tools work:

import sys, importlib.abc, importlib.util

SOURCES = {
    "virtual_greet": "def hello(name):\n    return f'hello {name} from memory'\n",
}

class DictFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
    def find_spec(self, name, path, target=None):
        if name in SOURCES:
            return importlib.util.spec_from_loader(name, self, origin="<memory>")
        return None
    def exec_module(self, module):
        exec(compile(SOURCES[module.__name__], module.__spec__.origin, "exec"), module.__dict__)

sys.meta_path.insert(0, DictFinder())
import virtual_greet
print(virtual_greet.hello("Ada"), virtual_greet.__spec__.origin)
hello Ada from memory <memory>

Returning None from find_spec lets the next finder try, so the custom finder only handles the names it knows about.

Concept and tricky questions

Drills

ConceptWhat are the steps of import mod, and which one makes modules singletons?

Answer

Answer. Check sys.modules; ask the finders on sys.meta_path for a spec; create the module and insert it into sys.modules; execute its code in the module's namespace; bind the name. The sys.modules cache (step 1) makes later imports return the same object without re-running the code.

Why. Inserting the module before executing it is also what allows circular imports to return a half-initialised module rather than recursing forever.

TrickyAfter from config import DEBUG, another module sets config.DEBUG = True. Does your DEBUG change?

Answer

Answer. No. from config import DEBUG bound your own name to the object that config.DEBUG referred to at import time. Rebinding the attribute in the module does not touch your name.

Why. Read config.DEBUG at use time if it can change. The same issue makes unittest.mock.patch require the path where the name is used, not where it was defined.

ConceptWhy can a file named json.py in your project break code that does import json?

Answer

Answer. The script's directory is the first entry on sys.path, so PathFinder finds your json.py before the standard library's json package.

Why. It is called shadowing. Isolated mode (-I) or -P (3.11+) removes the script directory from the path, but renaming the file is the real fix.

ConceptWhat is the difference between a regular package and a namespace package?

Answer

Answer. A regular package is a directory with __init__.py, which runs on import and gives the package a fixed location. A namespace package has no __init__.py; its __path__ can combine directories from several places on sys.path, and it has no initialisation code.

Why. Namespace packages let separately installed distributions share a top-level name (for example google.cloud.*). Forgetting __init__.py accidentally makes a namespace package, which works but can pick up stray directories.

TrickyWhy does from a import X fail in a circular import when import a works?

Answer

Answer. During the cycle, a is in sys.modules but partly executed. import a only needs the module object, which exists. from a import X needs the attribute X right now, and it is not defined yet, so it raises ImportError: cannot import name 'X' from partially initialized module 'a'.

Why. Deferring the attribute access (reading a.X inside a function) waits until both modules have finished.

ConceptWhat is in a .pyc file header and how does Python decide whether it is stale?

Answer

Answer. A magic number for the bytecode format, a flags word, and either the source's modification time and size or a hash of the source (PEP 552). If the magic number differs, or the recorded timestamp/size (or hash) does not match the current source, the module is recompiled.

Why. Timestamp validation is cheap but can be fooled by tools that preserve mtimes; hash-based .pyc files are reproducible and used by distribution builders.

TrickyWhy do some programs see their main script's module-level code run twice?

Answer

Answer. The script runs as __main__. If another module imports it by its file name (import app), sys.modules has no app entry, so the file is imported again as a separate module, running its top-level code a second time with separate globals.

Why. Avoid importing the entry script; put shared code and state in a module and keep the script as a thin launcher.

Predict the output and find the bug

Drills

OutputWhat does this print?

import sys, types

fake = types.ModuleType("math")
fake.pi = 3
sys.modules["math"] = fake
import math
print(math.pi)
del sys.modules["math"]
import math
print(round(math.pi, 2))
Answer
3
3.14

Answer. 3, then 3.14. While the fake module is in sys.modules, import math returns it without searching. After removing the entry, the next import finds the real built-in module.

Why. sys.modules is consulted first and trusted completely. Tests use this to inject fakes, and it is also how a broken half-import can poison later imports.

OutputWhat does this print?

import sys
print(__name__, __spec__ is None, "__main__" in sys.modules)
import importlib.util
print(importlib.util.find_spec("collections").submodule_search_locations is not None)
Answer
__main__ True True
True

Answer. __main__ True True, then True. A script run directly is the __main__ module and has no spec (running with -m would give it one). collections is a package, so its spec has submodule search locations.

Why. python -m pkg.module sets __spec__ and __package__, which is what makes relative imports work in modules run as scripts.

OutputWhat does this print?

import sys, os, tempfile

d = tempfile.mkdtemp()
for name, src in {
    "alpha.py": "print('alpha start')\nimport beta\nprint('alpha end')\n",
    "beta.py": "print('beta start')\nimport alpha\nprint('beta sees alpha:', hasattr(alpha, 'X'))\nprint('beta end')\n",
}.items():
    with open(os.path.join(d, name), "w") as f:
        f.write(src)
sys.path.insert(0, d)
import alpha
Answer
alpha start
beta start
beta sees alpha: False
beta end
alpha end

Answer. alpha start, beta start, beta sees alpha: False, beta end, alpha end. When beta imports alpha, the half-finished alpha is returned from sys.modules, and beta continues.

Why. No error occurs because beta only uses alpha as a module object; it would fail if it needed an attribute alpha defines later.

Find the bugA test patches requests.get but the real network call still happens. Find the bug.

import sys, types
from unittest import mock

net = types.ModuleType("net")
exec("def get(url):\n    return 'REAL ' + url\n", net.__dict__)
sys.modules["net"] = net

client = types.ModuleType("client")
exec("from net import get\ndef fetch():\n    return get('/users')\n", client.__dict__)
sys.modules["client"] = client

with mock.patch("net.get", return_value="FAKE"):
    print(client.fetch())
with mock.patch("client.get", return_value="FAKE"):
    print(client.fetch())
Answer
REAL /users
FAKE

Answer. client did from net import get, so it holds its own reference. Patching net.get replaces the attribute on net, which client never reads again. Patch where the name is looked up: client.get.

Why. The mock documentation calls this "patch where an object is used, not where it is defined", and it follows directly from how from … import binds names.

Find the bugStart-up is slow because a CLI imports a heavy library that most commands never use. Fix it without changing the commands' code.

import sys, types, time

heavy = types.ModuleType("heavy")
exec("import time\ntime.sleep(0.2)\ndef analyse():\n    return 'analysed'\n", heavy.__dict__)

cli_src = '''
import importlib
_lazy = {"analyse": "heavy"}
def __getattr__(name):
    if name in _lazy:
        module = importlib.import_module(_lazy[name])
        return getattr(module, name)
    raise AttributeError(name)
def version():
    return "1.0"
'''
cli = types.ModuleType("cli")
exec(cli_src, cli.__dict__)
sys.modules["cli"] = cli

t0 = time.perf_counter()
import cli
print(cli.version(), "fast import:", time.perf_counter() - t0 < 0.1)
sys.modules["heavy"] = heavy
print(cli.analyse())
Answer
1.0 fast import: True
analysed

Answer. The original cli did from heavy import analyse at the top, paying for heavy on every start. The fix uses a module-level __getattr__ (PEP 562) to import heavy only when cli.analyse is first accessed.

Why. python -X importtime shows which imports dominate start-up. Lazy loading through module __getattr__ keeps the public API unchanged.

Write the code

Coding

CodeWrite import_from_path(name, path) that imports a single Python file as a module under a given name, without adding its directory to sys.path, and registers it in sys.modules.

Estimated difficulty: about 1300 (Codeforces-style)

Answer
import sys, os, tempfile, importlib.util

def import_from_path(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    if spec is None:
        raise ImportError(f"cannot load {path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    try:
        spec.loader.exec_module(module)
    except BaseException:
        del sys.modules[name]
        raise
    return module

d = tempfile.mkdtemp()
path = os.path.join(d, "plugin-v2.py")
with open(path, "w") as f:
    f.write("NAME = 'v2'\ndef run():\n    return NAME.upper()\n")
plugin = import_from_path("plugin_v2", path)
print(plugin.run(), sys.modules["plugin_v2"] is plugin, d in sys.path)
V2 True False

Registering in sys.modules before exec_module mirrors what the import system does, so the module can import itself (or be part of a cycle). Removing it on failure prevents a broken, half-executed module from being cached.

CodeWrite import_graph(package_dir) that parses every .py file in a directory with ast, without importing anything, and returns which local modules each one imports. Use it to detect a cycle.

Estimated difficulty: about 1400 (Codeforces-style)

Answer
import ast, os, tempfile

def import_graph(directory):
    modules = {f[:-3] for f in os.listdir(directory) if f.endswith(".py")}
    graph = {}
    for mod in sorted(modules):
        with open(os.path.join(directory, mod + ".py")) as f:
            tree = ast.parse(f.read())
        deps = set()
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                deps.update(a.name.split(".")[0] for a in node.names)
            elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
                deps.add(node.module.split(".")[0])
        graph[mod] = sorted(deps & modules)
    return graph

def find_cycle(graph):
    state = {}
    def visit(n, path):
        state[n] = "active"
        for m in graph[n]:
            if state.get(m) == "active":
                return path[path.index(m):] + [m]
            if m not in state:
                found = visit(m, path + [m])
                if found:
                    return found
        state[n] = "done"
    for n in graph:
        if n not in state:
            c = visit(n, [n])
            if c:
                return c

d = tempfile.mkdtemp()
for name, src in {"app.py": "import models\nimport os\n", "models.py": "from db import connect\n",
                  "db.py": "import models\n", "util.py": ""}.items():
    with open(os.path.join(d, name), "w") as f:
        f.write(src)
g = import_graph(d)
print(g)
print(find_cycle(g))
{'app': ['models'], 'db': ['models'], 'models': ['db'], 'util': []}
['models', 'db', 'models']

Static analysis is how tools such as pydeps and linters find import cycles without running module code (which could have side effects).

CodeWrite a meta path finder that logs every import (name and time taken, including nested imports, indented by depth) by wrapping the normal machinery, then import a standard-library module that is not loaded yet.

Estimated difficulty: about 1500 (Codeforces-style)

Answer

Delegate to the rest of sys.meta_path to find the spec, then wrap the loader's exec_module.

import sys, time, importlib.abc

class ImportTracer(importlib.abc.MetaPathFinder):
    depth = 0
    log = []

    def find_spec(self, name, path, target=None):
        for finder in sys.meta_path:
            if finder is self or not hasattr(finder, "find_spec"):
                continue
            spec = finder.find_spec(name, path, target)
            if spec is not None and spec.loader is not None and hasattr(spec.loader, "exec_module"):
                original = spec.loader.exec_module
                tracer = self
                def exec_module(module, original=original, name=name):
                    tracer.depth += 1
                    t0 = time.perf_counter()
                    try:
                        original(module)
                    finally:
                        tracer.depth -= 1
                        tracer.log.append((tracer.depth, name, time.perf_counter() - t0))
                spec.loader.exec_module = exec_module
                return spec
            if spec is not None:
                return spec
        return None

tracer = ImportTracer()
sys.meta_path.insert(0, tracer)
import fractions
sys.meta_path.remove(tracer)
for depth, name, secs in reversed(tracer.log[-6:]):
    print("  " * depth + name)
print("fractions" in [n for _, n, _ in tracer.log])
fractions
  decimal
    _decimal
    numbers
True

The log is appended when each module finishes, so it is printed reversed to show parents before children. It records the name being imported rather than module.__name__, because some C extensions name themselves differently: _decimal calls itself decimal. python -X importtime gives the same information without code.

CodeWrite a deprecated_alias(module_globals, old, new) helper that installs a module-level __getattr__ so that accessing old returns new's value with a DeprecationWarning, while other missing names still raise AttributeError.

Estimated difficulty: about 1200 (Codeforces-style)

Answer
import sys, types, warnings

def deprecated_alias(g, old, new):
    previous = g.get("__getattr__")
    def __getattr__(name):
        if name == old:
            warnings.warn(f"{old} is deprecated; use {new}", DeprecationWarning, stacklevel=2)
            return g[new]
        if previous:
            return previous(name)
        raise AttributeError(f"module {g['__name__']!r} has no attribute {name!r}")
    g["__getattr__"] = __getattr__

lib = types.ModuleType("lib")
lib.connect_db = lambda: "connected"
deprecated_alias(lib.__dict__, "open_db", "connect_db")
sys.modules["lib"] = lib

import lib
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    print(lib.open_db(), str(w[0].message))
try:
    lib.missing
except AttributeError as e:
    print("AttributeError:", e)
connected open_db is deprecated; use connect_db
AttributeError: module 'lib' has no attribute 'missing'

Chaining to a previous __getattr__ lets several aliases be installed on the same module.

Cheat sheet

Theory
  • import m: sys.modules → sys.meta_path finders (BuiltinImporter, FrozenImporter, PathFinder) → spec → module inserted into sys.modules → code executed → name bound.
  • Module code runs once per process; importlib.reload re-runs it in the same object, but from m import x copies are stale.
  • sys.path: script dir (or cwd), PYTHONPATH, stdlib, site-packages + .pth files. -I / -P drop the script dir. Never name files like stdlib modules.
  • Regular package = __init__.py + __path__; namespace package = no __init__.py, can span directories. Relative imports need __package__ (use python -m).
  • The script is __main__; importing it by name runs it again as a second module.
  • Circular imports return partially initialised modules: import m + attribute access at call time works, from m import X fails.
  • .pyc in __pycache__/m.cpython-312.pyc: 16-byte header (magic, flags, mtime+size or source hash) + marshalled code object. Only imported modules are cached.
  • Module __getattr__/__dir__ (PEP 562) for lazy and deprecated names; custom finders on sys.meta_path implement find_spec.
  • Patch names where they are used (client.get), not where they are defined.

Part G · Mixed practice · Chapter 13

Timed mock rounds

Five rounds that mix the whole guide the way real interviews do: a question about default arguments turns into one about the bytecode, a dict question turns into hashing and resizing, and a threading question turns into the GIL. Set a timer, answer before opening anything, and score yourself honestly.

TheoryDrillsCoding· about 3 hours· prerequisites: Chapters 1–12

How to run a round

Theory
  • Give yourself the time shown for each round, and answer every question out loud or in writing before you open the answer.
  • For predict-the-output questions, write the exact output, including order and formatting. Half right counts as wrong in an interview.
  • Score 1 for a correct answer with the right reason, ½ for the right answer with a vague reason, 0 otherwise. 80% or more on a round means the topic is ready.
  • After each round, go back to the chapter behind every miss. The chapter is named in each answer.
RoundFocusTime
1Generalist warm-up15 min
2Predict the output20 min
3Internals deep dive20 min
4Debugging20 min
5Coding45 min

Round 1: generalist warm-up

Drills

ConceptIs Python compiled or interpreted?

Answer

Answer. Both. CPython compiles source to bytecode (cached in .pyc files) and then interprets that bytecode in its eval loop; since 3.11 the interpreter also specialises instructions at run time. (Chapter 3.)

Why. "Interpreted" alone suggests the source is re-parsed line by line, which is not what happens.

ConceptIs Python pass-by-value or pass-by-reference?

Answer

Answer. Neither, exactly: it is "pass by object reference" (call by sharing). The function receives references to the same objects, so it can mutate them, but rebinding a parameter does not affect the caller's variable. (Chapter 1.)

Why. The two classic demos: items.append(1) inside a function is visible outside; items = [1] inside a function is not.

ConceptWhat is the time complexity of x in some_list, x in some_set and some_list.pop(0)?

Answer

Answer. O(n), O(1) on average, and O(n). (Chapter 5.)

Why. Lists scan and shift pointers; sets hash.

TrickyWhy are dictionary keys required to be hashable, and why is a list not hashable?

Answer

Answer. A dict finds a key by its hash, so the hash must not change while the key is stored and equal keys must hash equally. A list is mutable and equality depends on its contents, so any hash based on contents could change after insertion. list.__hash__ is therefore None. (Chapter 5.)

Why. Tuples are hashable only when everything inside them is.

ConceptWhat does the GIL prevent, and what does it not prevent?

Answer

Answer. It prevents two threads from executing Python bytecode at the same time in one interpreter, protecting refcounts and object internals. It does not prevent race conditions in your code, because a thread can be switched out between any two bytecodes of a compound operation. (Chapter 11.)

Why. "GIL means thread-safe" is the most common wrong answer.

ConceptWhat is the difference between a generator and a list comprehension?

Answer

Answer. A list comprehension builds the whole list immediately. A generator expression produces one value at a time on demand, uses constant memory and can only be iterated once. (Chapter 10.)

Why. Use a generator for large or infinite streams and pipelines; use a list when you need indexing, len or several passes.

TrickyWhat is __slots__ for, and what is its main downside?

Answer

Answer. It replaces the per-instance __dict__ with fixed fields, saving memory and making attribute access faster. Downsides: no new attributes, no weak references unless __weakref__ is listed, and every subclass must declare slots to keep the benefit. (Chapter 7.)

Why. Interviewers expect "use it for millions of small objects", not "use it everywhere".

ConceptWhat happens when the refcount of an object reaches zero, and what handles the cases refcounting cannot?

Answer

Answer. The object is deallocated immediately: its type's tp_dealloc decrements everything it references and frees its memory (back to pymalloc for small objects). Reference cycles never reach zero; the generational cycle collector finds and frees them. (Chapter 4.)

Why. Deterministic freeing is a CPython property; other implementations may free later.

Round 2: predict the output

Drills

OutputWhat does this print?

def f(x, acc=[]):
    acc.append(x)
    return acc

print(f(1), f(2, []), f(3))
Answer
[1, 3] [2] [1, 3]

Answer. [1, 3] [2] [1, 3]: the first and third calls share the default list, and both print the same object after both appends. (Chapter 2.)

Why. Default values are evaluated once at def time. print receives the shared list twice and shows its final state.

OutputWhat does this print?

def make():
    return [lambda: i * 10 for i in range(3)]

print([fn() for fn in make()])
print([fn() for fn in [lambda i=i: i * 10 for i in range(3)]])
Answer
[20, 20, 20]
[0, 10, 20]

Answer. [20, 20, 20], then [0, 10, 20]. All lambdas in the first list share the comprehension's i, which ends at 2; the default argument freezes each value. (Chapter 2.)

Why. Closures capture variables, not values.

OutputWhat does this print?

a = 256
b = int("256")
c = 257
d = int("257")
print(a is b, c is d, c == d)
t = ([],)
t[0].append(1)
print(t, len(t[0]))
Answer
True False True
([1],) 1

Answer. True False True, then ([1],) 1. 256 is in the small-int cache; 257 parsed at run time is a new object. Tuples cannot be rebound but their mutable contents can change. (Chapter 1.)

Why. is reflects caching; == is the only correct numeric comparison.

OutputWhat does this print?

class A:
    def hi(self):
        return "A"

class B(A):
    def hi(self):
        return "B>" + super().hi()

class C(A):
    def hi(self):
        return "C>" + super().hi()

class D(B, C):
    pass

print(D().hi(), [k.__name__ for k in D.__mro__])
Answer
B>C>A ['D', 'B', 'C', 'A', 'object']

Answer. B>C>A ['D', 'B', 'C', 'A', 'object']. super() inside B follows the MRO of the D instance, so it reaches C before A. (Chapter 8.)

Why. super() means "next in the MRO of type(self)", not "my parent".

OutputWhat does this print?

def g():
    try:
        yield 1
        yield 2
    finally:
        print("cleanup")

it = g()
print(next(it))
it.close()
print(list(g()))
Answer
1
cleanup
cleanup
[1, 2]

Answer. 1, cleanup (from close()), then the second generator runs to completion: cleanup is printed during list(), then [1, 2]. (Chapter 10.)

Why. close() raises GeneratorExit at the paused yield, so finally runs. A generator that finishes normally also runs it.

OutputWhat does this print?

d = {}
d[1] = "a"
d[1.0] = "b"
d[True] = "c"
keys = list(d)
print(d, type(keys[0]).__name__)
s = {"x", "y"}
print(len(s | {"x"}), sorted(s & {"y", "z"}))
Answer
{1: 'c'} int
2 ['y']

Answer. {1: 'c'} int, then 2 ['y']. 1, 1.0 and True are equal with equal hashes, so they are one key; the first key object is kept and the value is replaced. (Chapter 5.)

Why. Dict lookup uses hash and ==, not type.

OutputWhat does this print?

import asyncio

async def step(name, log):
    log.append(f"{name}1")
    await asyncio.sleep(0)
    log.append(f"{name}2")

async def main():
    log = []
    await asyncio.gather(step("a", log), step("b", log))
    await step("c", log)
    print(log)

asyncio.run(main())
Answer
['a1', 'b1', 'a2', 'b2', 'c1', 'c2']

Answer. ['a1', 'b1', 'a2', 'b2', 'c1', 'c2']. gather runs a and b as tasks that interleave at each await; c runs after both are done. (Chapter 10.)

Why. Tasks switch only at awaits that are not ready; sleep(0) always yields once.

OutputWhat does this print?

x = [1, 2]
y = x
x += [3]
z = x
x = x + [4]
print(x, y, z, y is z)
Answer
[1, 2, 3, 4] [1, 2, 3] [1, 2, 3] True

Answer. [1, 2, 3, 4] [1, 2, 3] [1, 2, 3] True. += extends the shared list in place; x + [4] builds a new list and rebinds only x. (Chapters 1 and 7.)

Why. __iadd__ mutates; __add__ creates.

Round 3: internals deep dive

Drills

ConceptWalk through what happens, inside CPython, when you write d[k] = v for a dict that is two-thirds full.

Answer

Answer. Compute hash(k); use its low bits to choose a slot in the index table and probe with the perturbed recurrence until finding the key or an empty slot. Since inserting would exceed the 2/3 load factor, the dict first resizes: allocate a larger power-of-two index table and entries array, and re-insert every live entry (dropping tombstones). Then append the new (hash, key, value) entry and store its index in the slot. (Chapter 5.)

Why. This is why appends to a dict are amortised O(1) and iteration order is insertion order.

ConceptExplain how obj.method() finds and calls the method.

Answer

Answer. object.__getattribute__ searches type(obj).__mro__ and finds a function in a class dict. It checks for a data descriptor (no), then the instance __dict__ (no method there), then calls the function's __get__(obj, type(obj)), which produces a bound method; calling it passes obj as self. In 3.11+ the interpreter's LOAD_ATTR method form skips creating the bound method object. (Chapters 3 and 8.)

Why. The descriptor protocol is what makes self work.

TrickyA long-running service's memory stays at its peak after a big batch job finishes and all the data is deleted. Is it a leak? How would you find out?

Answer

Answer. Probably not. pymalloc can return an arena to the OS only when all its pools are empty, and the C allocator keeps freed memory too, so RSS stays high while Python reuses the space. To check for a leak, run the job repeatedly: a leak keeps growing; fragmentation plateaus. Use tracemalloc snapshots before and after to see which lines still hold memory. (Chapter 4.)

Why. Distinguishing fragmentation from leaks is a common senior-level question.

ConceptWhat is the specialising adaptive interpreter and how can you see it?

Answer

Answer. PEP 659 (3.11+): instructions such as LOAD_ATTR, BINARY_OP and CALL are rewritten in place into type-specific versions after warming up, with guards stored in inline caches; they de-specialise if the guards keep failing. dis.dis(fn, adaptive=True) after calling fn many times shows names like BINARY_OP_ADD_INT. (Chapter 3.)

Why. It rewards type-stable code and is the basis for the 3.13 experimental JIT.

TrickyWhy is "".join(parts) preferred over s += piece in a loop, if CPython can make += fast?

Answer

Answer. The += optimisation applies only when the string has exactly one reference and is a local variable; attributes, globals and shared strings defeat it, and other implementations do not have it, making the loop O(n²). join is O(total length) everywhere. (Chapter 6.)

Why. Relying on a refcount-dependent optimisation is fragile.

ConceptHow does the cycle collector decide which objects are garbage, without knowing the program's roots?

Answer

Answer. For each tracked container in the generation, it copies the refcount and subtracts the references coming from other objects in the same set. Objects with a remaining count are referenced from outside (roots); everything reachable from them survives. The rest are unreachable cycles and are finalised and cleared. (Chapter 4.)

Why. Any reference not explained by the set must come from outside, so the collector never needs a list of roots.

TrickyWhy can from module import name give you a stale value, and how does that affect mocking?

Answer

Answer. It binds a new name to the object at import time. If module.name is later rebound (reload, monkeypatching, mock.patch("module.name")), your binding still points at the old object. Patch the name where it is used, for example mock.patch("myapp.client.name"). (Chapter 12.)

Why. Names are references, and from … import is just an assignment.

Round 4: debugging

Drills

Find the bugThis cache is supposed to be per instance but is shared by all instances. Find the bug.

class Service:
    cache = {}
    def get(self, key):
        if key not in self.cache:
            self.cache[key] = f"value for {key}"
        return self.cache[key]

a, b = Service(), Service()
a.get("x")
print(b.cache)

class FixedService:
    def __init__(self):
        self.cache = {}
    get = Service.get

c, d = FixedService(), FixedService()
c.get("x")
print(d.cache)
Answer
{'x': 'value for x'}
{}

Answer. cache = {} is a class attribute, so self.cache reads the one dict shared by every instance and mutates it. Create it in __init__. (Chapter 8.)

Why. Reading self.attr falls through to the class; only assignment would create an instance attribute.

Find the bugA background thread updates a progress counter; the final count is sometimes wrong. Find the bug.

import threading

class Progress:
    def __init__(self):
        self.done = 0
        self._lock = threading.Lock()
    def step(self):
        with self._lock:
            self.done += 1

p = Progress()
threads = [threading.Thread(target=lambda: [p.step() for _ in range(10_000)]) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(p.done)
Answer
40000

Answer. The original step did self.done += 1 without the lock. That is a load-add-store sequence, and a thread switch in the middle loses updates. The fixed version holds a lock around it. (Chapter 11.)

Why. The GIL makes each bytecode atomic, not the sequence.

Find the bugThis parser's error handling hides real bugs. What is wrong?

def parse_port(text):
    try:
        port = int(text)
    except ValueError:
        return None
    if not 0 < port < 65536:
        raise ValueError(f"port out of range: {port}")
    return port

print(parse_port("8080"), parse_port("http"))
try:
    parse_port("70000")
except ValueError as e:
    print("ValueError:", e)
Answer
8080 None
ValueError: port out of range: 70000

Answer. The original wrapped everything in try: … except Exception: return None, so a typo (NameError), a range error and bad input all looked like "no port". The fix catches only the expected ValueError from int(), around only that line, and lets other errors propagate. (Chapter 3 shows that try is free; the cost is in what it hides.)

Why. Keep try blocks small and catch specific exceptions.

Find the bugAn async web handler makes every request slow under load. Find the bug.

import asyncio, time

def legacy_lookup(user):
    time.sleep(0.05)
    return {"user": user}

async def handler(user):
    return await asyncio.to_thread(legacy_lookup, user)

async def main():
    t0 = time.perf_counter()
    await asyncio.gather(*(handler(u) for u in range(10)))
    print("10 requests concurrently:", time.perf_counter() - t0 < 0.3)

asyncio.run(main())
Answer
10 requests concurrently: True

Answer. The original handler called legacy_lookup(user) directly. That blocking call froze the event loop, so 10 requests took 10 × 50 ms one after another. Running it with asyncio.to_thread (or using an async client) lets the loop serve other requests while it waits. (Chapter 10.)

Why. Every blocking call inside a coroutine serialises the whole server.

Find the bugEqual Points are not found in a set. Find the bug.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
        return hash((self.x, self.y))

seen = {Point(1, 2)}
print(Point(1, 2) in seen)
Answer
True

Answer. The original defined __eq__ but its __hash__ was return id(self) (or it inherited the identity hash through an explicit __hash__ = object.__hash__), so equal points landed in different slots. Hash the same fields that __eq__ compares. (Chapter 5.)

Why. Equal objects must have equal hashes, or sets and dicts silently fail.

Find the bugA plugin system registers plugins in a module, but the registry is always empty when read from the main script. Why?

import sys, os, tempfile, subprocess

d = tempfile.mkdtemp()
files = {
    "registry.py": "PLUGINS = []\n",
    "plugin_a.py": "import registry\nregistry.PLUGINS.append('a')\n",
    "main.py": "import registry\nimport plugin_a\nprint(registry.PLUGINS)\n",
}
for name, src in files.items():
    with open(os.path.join(d, name), "w") as f:
        f.write(src)
print(subprocess.run([sys.executable, "-s", "main.py"], cwd=d, capture_output=True, text=True).stdout.strip())
Answer
['a']

Answer. The original kept PLUGINS in main.py, and plugins did import main. That imported the script a second time as a separate module named main (the running script is __main__), so plugins appended to a different list. Moving the registry into its own module that both sides import fixes it. (Chapter 12.)

Why. Never import the entry-point script; it is not the same module object.

Round 5: coding

Coding

CodeWrite a decorator retry(times, exceptions) that retries a function on the listed exceptions with exponential back-off (sleep 0.01, 0.02, 0.04 …), re-raises the last exception after the final attempt, preserves the function's metadata, and records the number of attempts on the wrapper.

Estimated difficulty: about 1500 (Codeforces-style)

Answer
import functools, time

def retry(times=3, exceptions=(Exception,), base_delay=0.01):
    def decorate(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            delay = base_delay
            for attempt in range(1, times + 1):
                wrapper.attempts = attempt
                try:
                    return fn(*args, **kwargs)
                except exceptions:
                    if attempt == times:
                        raise
                    time.sleep(delay)
                    delay *= 2
        wrapper.attempts = 0
        return wrapper
    return decorate

calls = {"n": 0}

@retry(times=4, exceptions=(ConnectionError,))
def flaky():
    "Fails twice, then works."
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("try again")
    return "ok"

print(flaky(), flaky.attempts, flaky.__name__, flaky.__doc__)

@retry(times=2, exceptions=(ValueError,))
def always_bad():
    raise ValueError("permanent")

try:
    always_bad()
except ValueError as e:
    print("gave up after", always_bad.attempts, "attempts:", e)
ok 3 flaky Fails twice, then works.
gave up after 2 attempts: permanent

Bare raise inside the except block re-raises with the original traceback. Catching only the listed exceptions means a KeyError bug is not retried.

CodeImplement TTLCache(maxsize, ttl), a mapping where entries expire ttl seconds after being set, and the least recently used entry is evicted when full. Inject the clock so it can be tested without sleeping.

Estimated difficulty: about 1600 (Codeforces-style)

Answer
from collections import OrderedDict

class TTLCache:
    def __init__(self, maxsize, ttl, clock):
        self.maxsize, self.ttl, self.clock = maxsize, ttl, clock
        self.data = OrderedDict()       # key -> (expires_at, value)

    def __setitem__(self, key, value):
        self.data[key] = (self.clock() + self.ttl, value)
        self.data.move_to_end(key)
        while len(self.data) > self.maxsize:
            self.data.popitem(last=False)

    def __getitem__(self, key):
        expires, value = self.data[key]
        if self.clock() >= expires:
            del self.data[key]
            raise KeyError(key)
        self.data.move_to_end(key)
        return value

    def get(self, key, default=None):
        try:
            return self[key]
        except KeyError:
            return default

    def __len__(self):
        now = self.clock()
        for k in [k for k, (exp, _) in self.data.items() if exp <= now]:
            del self.data[k]
        return len(self.data)

now = [0.0]
cache = TTLCache(maxsize=2, ttl=10, clock=lambda: now[0])
cache["a"] = 1
cache["b"] = 2
cache.get("a")              # a is now most recently used
cache["c"] = 3              # evicts b
now[0] = 5
print(cache.get("a"), cache.get("b"), cache.get("c"), len(cache))
now[0] = 12
print(cache.get("a"), len(cache))
1 None 3 2
None 0

Passing the clock in (instead of calling time.monotonic() inside) is what makes time-based code testable, and monotonic rather than time.time is the right real clock because it never jumps backwards.

CodeWrite async def bounded_gather(coros, limit) that runs coroutines concurrently with at most limit running at once, returns results in input order, and cancels the rest if one fails. Show the peak concurrency.

Estimated difficulty: about 1700 (Codeforces-style)

Answer
import asyncio

async def bounded_gather(coros, limit):
    sem = asyncio.Semaphore(limit)
    async def run(c):
        try:
            async with sem:
                return await c
        finally:
            c.close()       # a coroutine cancelled before it started was never awaited
    async with asyncio.TaskGroup() as tg:       # 3.11+: cancels siblings on failure
        tasks = [tg.create_task(run(c)) for c in coros]
    return [t.result() for t in tasks]

active = 0
peak = 0

async def job(i, fail=False):
    global active, peak
    active += 1
    peak = max(peak, active)
    await asyncio.sleep(0.01)
    active -= 1
    if fail:
        raise ValueError(f"job {i} failed")
    return i * i

async def main():
    print(await bounded_gather([job(i) for i in range(10)], limit=3), "peak:", peak)
    try:
        await bounded_gather([job(i, fail=(i == 4)) for i in range(10)], limit=3)
    except* ValueError as eg:
        print("failed:", [str(e) for e in eg.exceptions])

asyncio.run(main())
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81] peak: 3
failed: ['job 4 failed']

TaskGroup cancels the remaining tasks when one raises and reports errors as an ExceptionGroup, handled with except*. The semaphore limits how many coroutines are inside await c at once, even though all tasks exist from the start. When the group cancels tasks still waiting on the semaphore, their coroutines were never started; closing them in finally avoids the "coroutine was never awaited" warning (closing a finished coroutine does nothing).