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.
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 comprehension
Generator expression
Syntax
[x for x in it]
(x for x in it)
Memory
O(n)
O(1)
When work happens
All at once, up front
One item at a time, on demand
Re-iterable
Yes
No. Once consumed, it is empty.
Supports len()
Yes
No
Best for
Small results you use more than once
Large 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.
Keyword
What it does
Use it when
nothing
Read from the nearest enclosing scope
Reading, or mutating an object in place
nonlocal x
Rebind x in the nearest enclosing function
A counter or best-so-far inside a nested DFS
global x
Rebind x at module level
Almost 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 use
What it does
@functools.cache
Memoises. Turns exponential recursion into linear. See Pattern 12.
@dataclass
Generates __init__, __repr__ and __eq__.
@property
Makes a method read like an attribute.
@staticmethod, @classmethod
Drops self, or replaces it with the class.
@functools.total_ordering
Fills 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 manager
What 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 this
To 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
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
Option
Effect
frozen=True
Immutable, and therefore hashable, so it can be a dict key.
slots=True
No per-instance __dict__. Roughly 40% less memory and faster attribute access. Python 3.10+.
order=True
Generates __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 share
Mutable, watch out
int, float, str, bool, tuple, frozenset, bytes
list, 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.
Fact
Consequence
Timsort, O(n log n)
O(n) on already-sorted or reverse-sorted runs. Real data sorts fast.
key is called once per element
An 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 right
Multi-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
Clause
Runs when
Use it for
try
Always
Keep it as small as possible. One risky call.
except
A matching exception was raised
Catch specific types, never bare except:
else
No exception was raised
The follow-up work, kept out of the try
finally
Always, including on return
Cleanup. 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}
Tool
Use it when
X | None
A value may be absent. Preferred over Optional[X] since 3.10.
TypeVar
The output type depends on the input type.
Protocol
You care what an object can do, not what it inherits from. Duck typing, checked.
TypedDict
A dict with fixed keys. Better than dict[str, Any].
Iterable in, list out
Accept broadly, return concretely.
Final, Literal
Constants, 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 is
Use
Why
I/O bound: network, disk, database
ThreadPoolExecutor or asyncio
The GIL is released while waiting, so threads overlap.
CPU bound: maths, parsing, compression
ProcessPoolExecutor
Separate processes mean separate GILs and real parallelism.
Thousands of concurrent connections
asyncio
One thread, cooperative switching. Far cheaper than a thread each.
Numeric arrays
NumPy, and threads
NumPy 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
Habit
Why
Profile before optimising
cProfile for where the time goes, timeit for micro-comparisons.
Prefer built-ins and comprehensions
sum, sorted, any and join run their loops in C.
Hoist invariants out of loops
Attribute and global lookups are dictionary lookups. Locals are array slots.
Choose the right container
An O(n) membership test is worth more than any micro-optimisation.
Removes 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
A generator is a paused function. It gives O(1) memory pipelines, and it is single-use.
Closures capture the variable, not its value. Freeze it with a default argument.
A decorator is f = decorator(f). Always add @functools.wraps.
A context manager guarantees cleanup. Return False from __exit__.
Implement dunder methods and your class works with the language. Keep __eq__ and __hash__ consistent.
Variables are names bound to objects. Rebinding is invisible to the caller; mutation is not.
Sorting is stable, so multi-key ordering is either a tuple key or two passes.
Threads for waiting, processes for computing. That is the GIL in one line.