How values come into being and how long they live. Python's flexibility around mutability is its biggest footgun; the rules here close the obvious traps.
from dataclasses import dataclass
from typing import ClassVar, Final
MAX_RETRIES: Final[int] = 3
_RETRYABLE_CODES: Final[frozenset[int]] = frozenset({429, 503})
@dataclass(frozen=True, slots=True)
class RetryPolicy:
attempts: int = MAX_RETRIES
backoff: float = 0.5
RETRYABLE: ClassVar[frozenset[int]] = _RETRYABLE_CODES
def expand(codes: list[int], extra: list[int] | None = None) -> list[int]:
if extra is None:
extra = []
seen: set[int] = set()
return [c for c in [*codes, *extra] if not (c in seen or seen.add(c))]
def first_retryable(codes: list[int]) -> int | None:
if (hit := next((c for c in codes if c in _RETRYABLE_CODES), None)) is not None:
return hit
return NoneMAX_RETRIES and _RETRYABLE_CODES are Final module constants in SCREAMING_SNAKE_CASE (4.3), the immutable set is a frozenset rather than a mutable set (4.2); RetryPolicy is a frozen, slotted dataclass (4.9) whose RETRYABLE is a ClassVar, not a per-instance field (4.4). expand defaults its collection argument to None and materializes inside (4.1) and annotates the widening local seen (4.6); first_retryable binds the walrus result once and reuses it (4.5). The module declares names and compiles values only — no side effects on import (4.7).
Reasoning, step by step:
def append(item, items=[]):evaluates the[]once at function definition time. Every call without anitemsargument shares the same list. State leaks across invocations.- This is the most well-known Python bug and somehow still ships.
- Fix: default to
None, materialize inside:def append(item: T, items: list[T] | None = None) -> list[T]: if items is None: items = [] items.append(item) return items
- Same trap for
dict,set, custom mutable objects. The fix is always:Nonedefault + initialize inside. - Ruff's
B006rule catches this. Enable it; treat warnings as errors.
Enforcement: Ruff B006 (mutable default argument), errors in CI.
Reasoning, step by step:
tupleis immutable.listis mutable. Picking the right one documents intent and prevents accidental mutation.- For constants and configured values:
tuple(orfrozenset/frozendict-equivalent viatypes.MappingProxyType). - For collections that do change:
list/set/dict. - Performance side: tuples are slightly cheaper to construct and iterate. For hot paths, this matters.
- Anti-pattern:
tuplebecause "I want immutability," then casting tolistat every use site. Pick the right type and stop.
Enforcement: review; mypy flags mutation of a tuple/frozenset-typed value.
Reasoning, step by step:
MAX_RETRIES: Final[int] = 3— mypy enforces no reassignment.SCREAMING_SNAKE_CASEfor the name, by convention.- For lazily-initialized "constants" (regexes, parsers, configs): use
functools.lru_cacheon a no-arg function, or a module-level call evaluated at import time. - Anti-pattern: module-level mutable state (
_cache: dict = {}). Either make it explicitly thread-safe (threading.Lock) or move it into a class.
Enforcement: mypy Final reassignment error; review for SCREAMING_SNAKE_CASE and module-level mutable state.
Reasoning, step by step:
- In a
@dataclass, plain class-level annotations become instance fields.ClassVarmarks them as class-level (shared, not per-instance). -
@dataclass class Config: name: str MAX_SIZE: ClassVar[int] = 1024 # class attribute, not a field
- Forget the
ClassVarand you've added a per-instance field by accident. - mypy enforces correct
ClassVarusage; trust it.
Enforcement: mypy validates ClassVar against dataclass field semantics.
Reasoning, step by step:
if (n := len(items)) > 10: print(f"got {n}")—nis bound once, used twice. Clearer than computinglen(items)twice.if (m := pattern.match(line)) is not None: process(m.group(1))— same idea: bind, test, use.- Anti-pattern:
if (x := foo()):when you don't reusex. Just writeif foo():. - Anti-pattern: walrus inside complex expressions for one-character savings. If the assignment isn't obvious to a fast reader, lift it out.
Enforcement: review; a walrus whose binding is used once is rejected.
Reasoning, step by step:
- Most local variables don't need annotations. mypy infers
x = 42asint. - Annotate locals when (a) the type isn't obvious from the right-hand side, (b) the variable's type widens later (
results: list[Result] = []), (c) you want to prevent accidental retyping. xs: list[int] = []is the canonical pattern. Otherwise mypy inferslist[Any].- Don't over-annotate. Three annotations per line is noise.
Enforcement: mypy --disallow-any-generics surfaces inferred list[Any]; review for over-annotation.
Reasoning, step by step:
- Importing a module should not hit the network, open a database connection, write a file, or print anything to stdout.
- Imports happen at unpredictable times — at startup, at test collection, when a tool introspects the module.
- Put initialization inside functions or class constructors. Module-level code should declare names, not perform actions.
- Acceptable module-level work: building lookup tables, compiling regexes, setting up
__all__and constants. Cheap, deterministic, side-effect-free.
Enforcement: review; import-time linters flagging I/O at module scope.
Reasoning, step by step:
del xunbindsx. Useful inside__slots__classes, in code that owns large buffers, or to make a name unavailable to subsequent code.del xis not a way to "free memory" — the object is freed when refcount hits zero, which may not be immediately afterdel.- Use
delsparingly. Most "I want to forget this variable" intentions are better served by smaller functions where the variable simply goes out of scope.
Enforcement: review; del used for genuine unbinding, not as a memory-management hint.
Reasoning, step by step:
__slots__declares a fixed set of attributes. Instances skip the per-object__dict__— faster attribute access, lower memory.@dataclass(slots=True)(3.10+) generates__slots__for you. Use it on every value-shaped dataclass.- Trade-off: no dynamic attribute assignment (
obj.new_attr = 1raisesAttributeError). For value types this is a feature; for plugin-shaped classes, it's a constraint. - Multi-inheritance + slots is finicky. Most value types don't multi-inherit, so this rarely matters.
Enforcement: review; value-shaped dataclasses carry slots=True.
Reasoning, step by step:
a = b = c = 0is fine — three names bound to the same value, clear intent.a, b, c = compute()is fine — unpacking from a tuple-return.a = compute_b(b := compute_c(c := init()))is not fine. Lift each step into its own line.- Rule: assignment chains are OK when they share a value or are a destructuring. Not OK when they smuggle in walrus operators or function calls.
Enforcement: review; chained assignment limited to shared values and unpacking.
from typing import Final, ClassVar
from dataclasses import dataclass
MAX_RETRIES: Final[int] = 3
_DEFAULT_TIMEOUT: Final[float] = 5.0
@dataclass(frozen=True, slots=True)
class PaymentConfig:
timeout: float = _DEFAULT_TIMEOUT
retries: int = MAX_RETRIES
REQUIRED_FIELDS: ClassVar[tuple[str, ...]] = ("amount", "currency")
# bad
def add(item, items=[]): # 4.1 — mutable default!
items.append(item)
return items
maxRetries = 3 # 4.3 — should be SCREAMING_SNAKE + Final
print("module loaded") # 4.7 — side effect on import- Dataclasses +
slots=True: chapter 06. Finaland type discipline: chapter 03.- Resource initialization patterns: chapter 13.