Python's idioms exist because they make code shorter, clearer, or safer. Used deliberately, they remove ceremony. Used carelessly, they produce code that looks Pythonic and reads like a puzzle.
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from itertools import islice
from pathlib import Path
@contextmanager
def line_reader(path: Path) -> Iterator[Iterator[str]]:
with path.open() as f: # paired lifecycle closes on any exit
yield (line.rstrip() for line in f) # stream lazily, never load the whole file
def active_emails(users: Iterable[User], limit: int) -> list[str]:
found = [u.email for u in users if u.active] # transform + filter in one shape
return list(islice(found, limit)) # bound the result
def lookup(prices: dict[str, int], sku: str) -> int:
try:
return prices[sku] # success case dominates: ask forgiveness
except KeyError as e:
raise UnknownSku(sku) from e # narrow, specific, chained
class Money:
def __init__(self, cents: int) -> None:
self.cents = cents
def __eq__(self, other: object) -> bool: # implement the protocol the class is part of
return isinstance(other, Money) and other.cents == self.cents
def __hash__(self) -> int:
return hash(self.cents) # __eq__ without __hash__ breaks set/dict useThe context manager closes the file on any exit and streams lines lazily rather than materializing them (7.1, 7.2); active_emails reads as transform-plus-filter and caps its output with islice (7.3, 7.10); lookup lets the dominant success path run and catches the one specific KeyError, chaining with from (7.4); pathlib.Path carries the path end to end (7.5); and Money implements __eq__ paired with __hash__ because it is a value, not for cleverness (7.8).
Reasoning, step by step:
with open(path) as f: data = f.read()closes the file on exit — normal or exceptional. Always safer than manualtry/finally.- The protocol:
__enter__returns the value;__exit__(exc_type, exc, tb)cleans up. ReturningTruefrom__exit__suppresses the exception (rarely correct; usually a footgun). - Use for: files, locks, transactions, temporary state changes, sockets, subprocess handles.
contextlib.contextmanagerfor ad-hoc context managers:@contextmanager def temporary_dir() -> Iterator[Path]: d = Path(tempfile.mkdtemp()) try: yield d finally: shutil.rmtree(d) ```
async withfor async resources. Coroutine-safe equivalent (chapter 09).
Enforcement: flake8-bugbear flags unclosed resources; review rejects manual open/close pairs outside a with.
Reasoning, step by step:
- A generator function (using
yield) returns a lazy iterator. Memory is bounded by the consumer's pull rate, not the producer's volume. - Use for: reading large files, producing potentially unbounded sequences, transforming an iterable lazily.
yield from inner_iterator()for delegation. Cleaner than afor x in inner: yield xloop.- Trap: generators are single-pass. Iterating twice doesn't restart — you get an empty iterator the second time.
- Generator vs comprehension:
(x * 2 for x in items)— generator expression. Lazy. Pass to functions expecting iterables.[x * 2 for x in items]— list comprehension. Materialized. Use when you need a list.
Enforcement: review; a for loop that only builds a list or accumulates a value is flagged for a generator or comprehension.
Reasoning, step by step:
[user.email for user in users if user.active]is the natural Pythonic shape for "transform + filter."- The comprehension stops being clearer when (a) it spans more than 2 lines, (b) it has multiple
if/forclauses that obscure intent, (c) the expression is a complex method chain. - Side effects in comprehensions are a code smell.
[print(x) for x in items]is wrong — that's aforloop. - Dict/set comprehensions:
{k: v for k, v in pairs},{x.id for x in items}. Same rules.
Enforcement: ruff (C4xx, B023) flags non-comprehension loops and side effects in comprehensions; review caps span at 2 lines.
Reasoning, step by step:
- Python's design favors EAFP: try the operation, catch the exception if it fails.
try: x = d["key"]; except KeyError: x = defaultorx = d.get("key", default). The latter is shorter; both are Pythonic.- EAFP wins when (a) the success case is dominant, (b) the check would be a TOCTOU race (file existence between check and open).
- LBYL (Look Before You Leap) wins when (a) the precondition is cheap and definitive (
if x is None), (b) the exception path has cost (allocations, logging). - Don't catch broadly to enable EAFP. Catch the specific exception you mean (
KeyError,ValueError), notException.
Enforcement: ruff (BLE001, E722) forbids bare except and broad except Exception; review checks the success case dominates.
Reasoning, step by step:
pathlibgives a typed, object-oriented path API.Path("/a") / "b" / "c.txt"reads naturally.os.pathis string-based and platform-fragile. Don't reach for it in new code.- Convert at the boundary: APIs that hand you a
strpath become aPathimmediately. APIs that want astrgetstr(path). - Common idioms:
path.read_text(),path.write_bytes(data),path.iterdir(),path.glob("*.json").
Enforcement: ruff (PTH ruleset) flags os.path calls in new code; review converts str paths to Path at the boundary.
Reasoning, step by step:
f"Hello, {name}! You have {len(cart)} items."is shorter, scoped at the use site, and the fastest formatter Python has.%-style andstr.formatare older and less ergonomic. Use them only for compatibility (rare) or logging (intentional — see chapter on logging).- Logging:
logger.info("user %s loaded", user_id)— the formatting is lazy, only happens if the level is enabled. f-strings format eagerly, even at DEBUG. - Anti-pattern: complex expressions inside f-strings. If
f"{complex.expression.with[many].parts}"is hard to read, lift it to avalabove.
Enforcement: ruff (G ruleset) flags f-strings in logging calls; flake8-logging-format enforces lazy %-style logging.
Reasoning, step by step:
- Python 3.10's structural pattern matching is the right tool for: discriminated unions, deeply-nested shape checks, multi-way branching by type or value.
match/caseis structural —case Approved(receipt_id=r):bindsrto the field.- Pair with
assert_neverfor exhaustiveness:match result: case Approved(): ... case Declined(): ... case _: assert_never(result) # mypy errors if a variant is added and not handled
- Anti-pattern:
matchfor what should be a simpleif/elif. Two cases — write theif. Four cases with structural binding — write thematch.
Enforcement: mypy errors when an assert_never arm is reachable (a variant went unhandled); review rejects match over a two-way if.
Reasoning, step by step:
- Dunders make a class participate in Python's protocols: iteration, containment, length, equality, ordering, formatting.
- Implement when your class is that thing. Don't implement them for cleverness.
- The big six to know:
__eq__+__hash__,__iter__,__len__,__contains__,__enter__+__exit__,__repr__. - Less common but useful:
__getitem__(indexable),__call__(callable),__bool__(truthiness — careful with this one),__getattr__(fallback attribute access).
Enforcement: ruff (PLE/W) flags __eq__ without __hash__; review rejects dunders implemented for cleverness rather than protocol membership.
Reasoning, step by step:
- Decorators wrap a function in another function. Good uses: caching (
@lru_cache), timing, logging, retry, transaction boundaries. - Stack decorators top-to-bottom in the order they wrap.
@log @retry @cache def f(): ...meanslog(retry(cache(f))). - Custom decorators always
@functools.wraps(func)to preserve name, docstring, signature. - Type-preserve. Use
ParamSpec(Callable[P, R]) to preserve the wrapped signature for mypy:def retry[**P, R](fn: Callable[P, R]) -> Callable[P, R]: ...
- Anti-pattern: decorator with side effects on import. Tests touching the module run the decorator's setup. Pure wrapping only.
Enforcement: ruff (B008) flags work at import time; review checks custom decorators carry @functools.wraps and preserve the signature via ParamSpec.
Reasoning, step by step:
for i, item in enumerate(items):overfor i in range(len(items)): item = items[i].for a, b in zip(xs, ys, strict=True):over manual paired indexing.strict=True(3.10+) errors on length mismatch.itertoolsis full of right answers:chain,groupby,islice(bounded slicing of any iterable),pairwise(3.10+),accumulate,product.- Bound your iterators.
itertools.islice(generator, max_items)caps potentially-unbounded sources (Tiger Style rule §9 — see root README).
Enforcement: ruff (B007, PLC0200) flags range(len(...)) indexing; review requires islice on unbounded sources.
Reasoning, step by step:
if x is None:andif x is not None:— explicit. Always for "is this missing?"if items:is OK for "are there any items?" (true for non-empty list, dict, set, str).- Bug:
if value:whenvaluecould be0or""and you wanted "missing." Useif value is not None:. - Bool conversion of complex objects (
if user:) is a smell unless you've defined__bool__. Be explicit.
Enforcement: ruff (E711/E712) requires is None/is not None; review flags if value: where 0 or "" could mean present.
Reasoning, step by step:
- Take
Iterable[T], notSequence[T], as a function parameter — the looser type is more flexible for callers. - Return
list[T](concrete) rather thanIterable[T](abstract) when callers will index or iterate twice. Be precise about return types. - Source the abstract types from
collections.abc:Iterable,Iterator,Sequence,Mapping,MutableMapping,Callable. Thetyping.*versions are deprecated aliases.
Enforcement: ruff (UP035) flags deprecated typing aliases; review checks parameters take the loosest abstract type.
7.13 — pathlib, dataclasses, functools, itertools, collections.abc, contextlib, enum are the stdlib modules.
Reasoning, step by step:
- Reach for stdlib first. The above modules cover 80% of what bespoke utility classes get written for.
- Third-party libraries that "replace" these usually add features you don't need and a dependency you do.
- Notable exceptions where third-party is the right call:
pydantic(validation at the boundary),httpx/requests(HTTP — stdliburllibis awkward),structlog(structured logging — stdlib works but is bare). - Every dependency is an attack surface and a future upgrade. Lean on stdlib.
Enforcement: review; a new third-party dependency duplicating a listed stdlib module is rejected outside the named exceptions.
from collections.abc import Iterator
from contextlib import contextmanager
from itertools import islice
from pathlib import Path
from typing import Literal, assert_never
@contextmanager
def temporary_workdir() -> Iterator[Path]:
d = Path(tempfile.mkdtemp())
try:
yield d
finally:
shutil.rmtree(d)
def first_n_lines(path: Path, n: int) -> list[str]:
with path.open() as f:
return list(islice((line.rstrip() for line in f), n))
match result:
case Approved(receipt_id=rid): logger.info("approved %s", rid)
case Declined(reason=why): logger.warning("declined: %s", why)
case _: assert_never(result)
# bad
for i in range(len(items)): # 7.10 — use enumerate
print(items[i])
result = "" # 7.6 — use f-string or join
for item in items:
result += str(item) + ", "
with open(path) as f: # 7.5 — use pathlib
data = f.read()async withand async context managers: chapter 09.- Generators in API design: chapter 10.
itertools.isliceand bounded iterators: chapter 13.