Guides Guide 5 Reference

Advanced Python

The layer above the toolkit. Generators, decorators, context managers, the data model, and the concurrency story. What a senior Python interview probes, and what a code review will catch.

Guide 1 covers the containers and their costs, which is enough to solve the sixteen patterns. This page covers the language features that separate working Python from Python a reviewer would approve. Every example runs.

Same convention as Guide 1. Lines that do real work carry their cost as a trailing comment. Average means an adversarial input could degrade one operation. Amortised means the guarantee holds across a sequence.

Contents

  1. Iterators and generators
  2. Closures, and the late-binding trap
  3. Decorators, written yourself
  4. Context managers
  5. The data model: dunder methods
  6. Names, mutability and copying
  7. Sorting, properly
  8. Exceptions and EAFP
  9. Typing beyond the basics
  10. Concurrency and the GIL
  11. Performance habits

Iterators and generators

What an iterator is. Any object you can call next() on to get one more value, until it raises StopIteration. A for loop is sugar for exactly that. What a generator is. A function containing yield. Calling it runs no body code; it returns an iterator. Each next() runs the body until the next yield, then pauses, keeping all local state, and hands the value back.
def countdown(n: int):
    """A generator function. `yield` makes it lazy.

    >>> list(countdown(3))
    [3, 2, 1]
    """
    while n > 0:
        yield n              # O(1) per item, and the function PAUSES here
        n -= 1


def flatten(groups: list[list[int]]):
    """`yield from` delegates to another iterable.

    >>> list(flatten([[1, 2], [3]]))
    [1, 2, 3]
    """
    for group in groups:
        yield from group     # equivalent to: for item in group: yield item


# A generator EXPRESSION: round brackets instead of square.
squares = (x * x for x in range(1_000_000))   # O(1) memory, nothing computed yet
first_square = next(squares)                  # O(1), computes exactly one value

Why it matters

List comprehensionGenerator expression
Syntax[x for x in it](x for x in it)
MemoryO(n)O(1)
When work happensAll at once, up frontOne item at a time, on demand
Re-iterableYesNo. Once consumed, it is empty.
Supports len()YesNo
Best forSmall results you use more than onceLarge or infinite streams, and pipelines
from itertools import islice


def integers_from(start: int):
    """An INFINITE generator. Perfectly safe, as long as you stop taking.

    >>> list(islice(integers_from(10), 3))
    [10, 11, 12]
    """
    current = start
    while True:
        yield current
        current += 1


def running_total(values):
    """A pipeline stage: consumes an iterable, produces an iterable.

    >>> list(running_total([1, 2, 3]))
    [1, 3, 6]
    """
    total = 0
    for value in values:
        total += value
        yield total          # O(1) memory no matter how long the input is
The pipeline idea. Chain generators and the data flows one item at a time from source to sink, so peak memory is O(1) rather than O(n) per stage. This is how you process a file larger than RAM, and it is the whole argument for generators in a data role. See Guide 6.
A generator is single-use. After you iterate it, it is exhausted, and a second loop sees nothing. If you need the values twice, materialise with list(...) or use itertools.tee. This silently produces empty results and is a real interview trap.

Closures, and the late-binding trap

What a closure is. A function defined inside another function that captures the enclosing local variables. Python captures the variable, not its value at definition time. So the inner function sees whatever the variable holds when the inner function is finally called.
def make_adders_wrong() -> list:
    """Every lambda captures `i` itself, so all three see its FINAL value.

    >>> [f(10) for f in make_adders_wrong()]
    [12, 12, 12]
    """
    return [lambda x: x + i for i in range(3)]


def make_adders_right() -> list:
    """A default argument is evaluated at DEFINITION time, freezing the value.

    >>> [f(10) for f in make_adders_right()]
    [10, 11, 12]
    """
    return [lambda x, offset=i: x + offset for i in range(3)]
This bites in real code. Building a list of callbacks, event handlers or partially applied functions inside a loop hits it every time. The two fixes are the default-argument trick above, or functools.partial(add, i), which also evaluates eagerly.

Scope: the LEGB rule

A bare name is looked up in four places, in order: Local, Enclosing function, Global (module), Built-in.

KeywordWhat it doesUse it when
nothingRead from the nearest enclosing scopeReading, or mutating an object in place
nonlocal xRebind x in the nearest enclosing functionA counter or best-so-far inside a nested DFS
global xRebind x at module levelAlmost never. It is a smell.
Assignment anywhere in a function makes that name local for the whole function, even on lines before the assignment. That is why count += 1 on an outer variable raises UnboundLocalError rather than reading the outer one. See the nested-function section of Guide 1.

Decorators, written yourself

What a decorator is. A function that takes a function and returns a replacement. The @name line above a def is pure sugar: @timed above def work means work = timed(work). That is the entire mechanism.
import functools
import time


def timed(func):
    """Wrap a function so its last runtime is recorded."""

    @functools.wraps(func)      # copies __name__, __doc__ and __wrapped__ over
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            wrapper.last_seconds = time.perf_counter() - start   # runs even on error
    return wrapper


@timed
def work(n: int) -> int:
    """Sum 0 to n-1.

    >>> work(5)
    10
    >>> work.__name__          # preserved by functools.wraps
    'work'
    """
    return sum(range(n))       # O(n)
Always use @functools.wraps. Without it the wrapped function reports the wrapper’s __name__ and loses its docstring, which breaks introspection, logging, and doctests. It is one line and it is not optional.

A decorator that takes arguments

Then you need one more layer: a function that returns a decorator that returns a wrapper.

import functools

attempts = 0


def retry(times: int):
    """A decorator FACTORY. retry(3) evaluates to the actual decorator."""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last: Exception | None = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except ValueError as err:
                    last = err
            assert last is not None
            raise last
        return wrapper

    return decorator


@retry(3)
def flaky() -> str:
    """Fails twice, then succeeds.

    >>> flaky()
    'ok'
    """
    global attempts
    attempts += 1
    if attempts < 3:
        raise ValueError("not ready yet")
    return "ok"
Decorator you already useWhat it does
@functools.cacheMemoises. Turns exponential recursion into linear. See Pattern 12.
@dataclassGenerates __init__, __repr__ and __eq__.
@propertyMakes a method read like an attribute.
@staticmethod, @classmethodDrops self, or replaces it with the class.
@functools.total_orderingFills in the other comparisons from __eq__ and __lt__.

Context managers

What it is. An object usable with with. Python calls __enter__ on the way in and guarantees __exit__ on the way out, whether the block finished, returned, or raised. It is try/finally with a name, and it is how you make cleanup impossible to forget.
import time
from contextlib import contextmanager


class Timer:
    """A context manager as a class.

    >>> with Timer() as t:
    ...     total = sum(range(100))
    >>> t.seconds >= 0
    True
    """

    def __enter__(self) -> "Timer":
        self._start = time.perf_counter()
        return self                     # this is what `as t` receives

    def __exit__(self, exc_type, exc, traceback) -> bool:
        self.seconds = time.perf_counter() - self._start
        return False                    # False: do NOT swallow an exception


@contextmanager
def swallow_value_errors():
    """The same contract from a generator, which is usually shorter.

    Everything before the yield is __enter__. Everything after is __exit__.

    >>> with swallow_value_errors():
    ...     raise ValueError("handled")
    >>> "still running"
    'still running'
    """
    try:
        yield
    except ValueError:
        pass
Returning True from __exit__ suppresses the exception. That is powerful and almost always wrong. Return False, or nothing, unless suppressing is the explicit purpose of the manager. Getting this backwards hides real failures.
Standard context managerWhat it guarantees
open(path)The file handle is closed.
threading.Lock()The lock is released, even on an exception.
contextlib.suppress(ValueError)Swallows just that exception type, readably.
contextlib.ExitStack()Manages a variable number of resources at once.
decimal.localcontext()Restores the previous numeric precision.

The data model: dunder methods

What the data model is. Python’s built-in operations are defined by protocols, not by inheritance. len(x) calls x.__len__(). x + y calls x.__add__(y). for i in x calls x.__iter__(). Implement the method and your own class works with the language itself. This is what “Pythonic” actually means.
class Deck:
    """Implement the protocols and the language does the rest.

    >>> deck = Deck([1, 2, 3])
    >>> len(deck)
    3
    >>> deck[0]
    1
    >>> 2 in deck
    True
    >>> [card for card in deck]
    [1, 2, 3]
    >>> deck
    Deck([1, 2, 3])
    """

    def __init__(self, cards: list[int]) -> None:
        self._cards = cards

    def __len__(self) -> int:
        return len(self._cards)         # O(1)

    def __getitem__(self, index: int) -> int:
        return self._cards[index]       # O(1); this ALONE gives iteration and `in`

    def __repr__(self) -> str:
        return f"Deck({self._cards!r})"  # !r calls repr on the inner value
Write thisTo support
__len__len(x), and truthiness when __bool__ is absent
__getitem__x[i], iteration, and in
__iter__for, list(x), unpacking. Preferred over relying on __getitem__.
__contains__A fast in, instead of the O(n) scan you get for free
__eq__ and __hash__Dict keys and set members
__lt__sorted(), min, max, and heaps
__repr__Debugging. Write it for every class.
__enter__ and __exit__with
__call__Making the instance callable like a function
The __eq__ and __hash__ contract. If two objects are equal they must hash the same, or dicts and sets break in ways that look like corruption. Defining __eq__ by hand silently sets __hash__ to None, making the class unhashable. This is exactly why Clone Graph uses a plain class rather than a dataclass: it needs default identity hashing.

dataclass options worth knowing

from dataclasses import dataclass


@dataclass(frozen=True, slots=True, order=True)
class Version:
    """frozen: immutable and hashable. slots: less memory. order: comparisons.

    >>> sorted([Version(1, 2), Version(1, 0)])
    [Version(major=1, minor=0), Version(major=1, minor=2)]
    >>> Version(1, 0) in {Version(1, 0)}
    True
    """

    major: int
    minor: int
OptionEffect
frozen=TrueImmutable, and therefore hashable, so it can be a dict key.
slots=TrueNo per-instance __dict__. Roughly 40% less memory and faster attribute access. Python 3.10+.
order=TrueGenerates __lt__ and friends, comparing fields in declaration order.
field(default_factory=list)The correct way to default to a mutable value. A bare = [] raises.

Names, mutability and copying

The model. A Python variable is a name bound to an object, never a box holding a value. b = a makes a second name for the same object. Nothing is copied. Whether that matters depends entirely on whether the object is mutable.
import copy

original = [1, [2, 3]]

same = original                 # O(1)  a second NAME, not a copy
shallow = original.copy()       # O(n)  new outer list, SAME inner list
deep = copy.deepcopy(original)  # O(size) everything duplicated

original[1].append(4)
# same[1]    -> [2, 3, 4]   it is literally the same object
# shallow[1] -> [2, 3, 4]   the inner list was shared
# deep[1]    -> [2, 3]      fully independent
Immutable, safe to shareMutable, watch out
int, float, str, bool, tuple, frozenset, byteslist, dict, set, bytearray, most of your own classes
Only immutable objects are hashable, which is why a tuple can be a dict key and a list cannot. A tuple containing a list is also unhashable, because hashing recurses into the contents.

Passing arguments

def rebind(items: list[int]) -> None:
    items = [9, 9]         # rebinds the LOCAL name only. Caller sees nothing.


def mutate(items: list[int]) -> None:
    items.append(9)        # mutates the shared object. Caller DOES see this.

Python is neither “by value” nor “by reference”. The reference is passed by value, so rebinding is invisible to the caller and mutation is not. If a function mutates its argument, say so in the docstring, as every in-place solution on this site does.

is versus ==. == asks “same value”, is asks “same object in memory”. CPython caches small integers and short strings, so 256 is 256 is True and 1000 is 1000 may not be. Never compare values with is. Reserve it for None, and for the deliberate identity checks in Pattern 3.

Sorting, properly

records = [("bob", 3), ("ann", 3), ("cid", 1)]

by_count = sorted(records, key=lambda r: r[1])              # O(n log n)
by_count_then_name = sorted(records, key=lambda r: (r[1], r[0]))
count_desc_name_asc = sorted(records, key=lambda r: (-r[1], r[0]))

# When the descending key is NOT numeric, negating is impossible.
# Use two stable passes, least significant key FIRST.
step_one = sorted(records, key=lambda r: r[0])                    # name ascending
two_pass = sorted(step_one, key=lambda r: r[1], reverse=True)     # count descending
Stable means equal keys keep their original relative order. Python’s sort is stable, guaranteed. That is what makes the two-pass trick work, and it is the reason sorted is safe to apply repeatedly to refine an order.
FactConsequence
Timsort, O(n log n)O(n) on already-sorted or reverse-sorted runs. Real data sorts fast.
key is called once per elementAn expensive key is fine. A cmp function would call it O(n log n) times.
sorted() copies, .sort() does not.sort() returns None. Never assign its result.
Tuples compare left to rightMulti-key sorting is just a tuple key.

When a key function is not enough

from functools import cmp_to_key


def largest_number(numbers: list[int]) -> str:
    """Arrange the numbers to form the largest possible value.

    No key function can express this: the order depends on comparing PAIRS,
    because "3" before "30" is decided by "330" versus "303".

    >>> largest_number([3, 30, 34, 5, 9])
    '9534330'
    """

    def compare(a: str, b: str) -> int:
        if a + b > b + a:
            return -1            # a should come first
        if a + b < b + a:
            return 1
        return 0

    ordered = sorted((str(n) for n in numbers), key=cmp_to_key(compare))
    joined = "".join(ordered)
    return "0" if joined[0] == "0" else joined      # all zeros collapse to "0"

Exceptions and EAFP

EAFP is “easier to ask forgiveness than permission”: try the operation and handle the failure. LBYL is “look before you leap”: check first, then act. Python leans EAFP, because a check plus an action is two lookups and has a race window between them.
LBYL: two lookups
counts = {"a": 1}
if "a" in counts:        # lookup 1
    value = counts["a"]  # lookup 2
else:
    value = 0
EAFP: one lookup
counts = {"a": 1}
try:
    value = counts["a"]  # lookup 1, and done
except KeyError:
    value = 0
# or simply: counts.get("a", 0)
class ConfigError(Exception):
    """Domain errors get their own class, so callers can catch precisely."""


def parse_port(raw: str) -> int:
    """Parse a port number, or raise ConfigError with a useful message.

    >>> parse_port("8080")
    8080
    >>> try:
    ...     parse_port("nope")
    ... except ConfigError as err:
    ...     print(err)
    port must be an integer, got 'nope'
    """
    try:
        port = int(raw)
    except ValueError as err:
        # `from err` keeps the original traceback attached as __cause__.
        raise ConfigError(f"port must be an integer, got {raw!r}") from err

    if not 1 <= port <= 65535:
        raise ConfigError(f"port out of range: {port}")

    return port

The four clauses

ClauseRuns whenUse it for
tryAlwaysKeep it as small as possible. One risky call.
exceptA matching exception was raisedCatch specific types, never bare except:
elseNo exception was raisedThe follow-up work, kept out of the try
finallyAlways, including on returnCleanup. A context manager is usually better.
Never write a bare except:. It catches KeyboardInterrupt and SystemExit too, so your program cannot be stopped. If you truly must catch everything, write except Exception:, and log it.

Typing beyond the basics

from typing import Iterable, Protocol, TypedDict, TypeVar

T = TypeVar("T")


def first(items: Iterable[T]) -> T | None:
    """Generic: whatever type goes in is the type that comes out.

    >>> first([1, 2])
    1
    >>> first([]) is None
    True
    """
    for item in items:
        return item
    return None


class SupportsLessThan(Protocol):
    """Structural typing. Anything with __lt__ satisfies this, no inheritance."""

    def __lt__(self, other: object) -> bool: ...


class Row(TypedDict):
    """A dict with a KNOWN shape, so the type checker can catch typos."""

    name: str
    score: int


row: Row = {"name": "ann", "score": 10}
ToolUse it when
X | NoneA value may be absent. Preferred over Optional[X] since 3.10.
TypeVarThe output type depends on the input type.
ProtocolYou care what an object can do, not what it inherits from. Duck typing, checked.
TypedDictA dict with fixed keys. Better than dict[str, Any].
Iterable in, list outAccept broadly, return concretely.
Final, LiteralConstants, and a fixed set of allowed values.
Annotations are not enforced at runtime. They are checked by mypy or pyright in CI, and they document intent for the next reader. In an interview they cost you nothing and signal care.

Concurrency and the GIL

What the GIL is. The Global Interpreter Lock is a single mutex that lets only one thread execute Python bytecode at a time. Threads still help, because the GIL is released during I/O and inside many C extensions. It only blocks you from using several CPU cores for pure-Python computation.
Your work isUseWhy
I/O bound: network, disk, databaseThreadPoolExecutor or asyncioThe GIL is released while waiting, so threads overlap.
CPU bound: maths, parsing, compressionProcessPoolExecutorSeparate processes mean separate GILs and real parallelism.
Thousands of concurrent connectionsasyncioOne thread, cooperative switching. Far cheaper than a thread each.
Numeric arraysNumPy, and threadsNumPy releases the GIL inside its C loops. See Guide 6.
from concurrent.futures import ThreadPoolExecutor


def fetch(url: str) -> int:
    """Stands in for an I/O call. The GIL is released while waiting."""
    return len(url)


with ThreadPoolExecutor(max_workers=4) as pool:
    lengths = list(pool.map(fetch, ["a", "bb", "ccc"]))     # [1, 2, 3]
The one-line answer. “Threads for waiting, processes for computing.” If an interviewer asks why Python threads do not speed up a tight numeric loop, that plus “the GIL serialises bytecode execution” is the complete answer.
Python 3.13 ships an optional free-threaded build with the GIL removed, and 3.14 makes it officially supported. It is not yet the default, and most deployments still run the standard build. Mentioning it shows you follow the language; assuming it is available does not.

Performance habits

import timeit

# Measure, do not guess.
elapsed = timeit.timeit("sum(range(100))", number=100)      # seconds for 100 runs


def hot_loop(values: list[int]) -> list[int]:
    """Bind the method once, outside the loop.

    Each `out.append(...)` inside a loop is an attribute lookup plus a call.
    Binding it to a local name removes the lookup from every iteration.

    >>> hot_loop([1, 2, 3])
    [2, 4, 6]
    """
    out: list[int] = []
    append = out.append          # O(1) once, instead of O(1) n times
    for value in values:
        append(value * 2)
    return out
HabitWhy
Profile before optimisingcProfile for where the time goes, timeit for micro-comparisons.
Prefer built-ins and comprehensionssum, sorted, any and join run their loops in C.
Hoist invariants out of loopsAttribute and global lookups are dictionary lookups. Locals are array slots.
Choose the right containerAn O(n) membership test is worth more than any micro-optimisation.
Generate, do not materialisePeak memory drives cache behaviour and, eventually, swapping.
Use __slots__ for many small objectsRemoves the per-instance dict. Big win at millions of instances.
Algorithmic complexity dominates everything on this list. Binding a method to a local saves a few percent. Swapping a list scan for a set lookup saves a factor of n. Fix the big-O first, and only then reach for these.

The eight things to carry forward


Guide 4 — Testing Your Own Code Guide 6 — ML and Data Processing