Design-time is the best time for 1000× improvements. Code-time is the best time for 10× ones. Profile-time is the only honest time for everything below 10×.
import asyncio
from dataclasses import dataclass
from functools import lru_cache
from itertools import batched
@dataclass(slots=True, frozen=True)
class Quote:
symbol: str
cents: int
@lru_cache(maxsize=1024)
def fee_for(tier: str) -> int: # pure, bounded memo
return _fee_table[tier]
def settle(quotes: list[Quote], active: frozenset[str]) -> int:
# stream the filter; build the set once, look up in O(1)
billable = (q for q in quotes if q.symbol in active)
return sum(q.cents + fee_for(_tier_of(q.symbol)) for q in billable)
async def fetch_all(symbols: list[str]) -> list[Quote]:
# batch the network round-trips, bound the fan-out per chunk
out: list[Quote] = []
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(_fetch_chunk(c)) for c in batched(symbols, 256)]
for t in tasks:
out.extend(t.result())
return outQuote is a slotted frozen dataclass for the hot value type (15.3, 15.13); fee_for memoizes a pure function with a bounded maxsize (15.5); settle streams through a generator rather than materializing the filtered list (15.4) and tests membership against a frozenset for O(1) lookup (15.8); fetch_all attacks the slowest resource first by batching network round-trips into bounded async chunks (15.1, 15.9). No module-level mutable state holds the accumulators (15.6).
Reasoning, step by step:
- A request that does 10 sequential network calls is hopeless regardless of how tight the Python is. Batch, parallelize, or cache the network first.
- Allocation pressure rarely wins from instruction-level tuning; reduce allocations first.
- Order of priority: eliminate the round-trip; batch the round-trips; cache the response; reduce allocations; tune the inner loop.
- CPU optimizations are last because (a) they're the smallest absolute wins on most server workloads, (b) the Python interpreter is the speed ceiling, (c) they're easiest to do wrong.
Enforcement: design review; perf work starts at the round-trip, not the inner loop.
Reasoning, step by step:
- Intuition about Python performance is wrong roughly half the time. The GIL, allocations, attribute lookup, and built-in function dispatch all interact in non-local ways.
py-spy— sampling profiler, no instrumentation, attaches to running processes. Best default for "where is time going?"cProfile— stdlib, instruments the program. Heavier overhead but exact call counts.memray— allocation profiling. Use when you suspect memory pressure.scalene— CPU + memory in one tool, distinguishes Python vs native time.- Rule: before "fixing" a perf issue, capture a profile that shows the issue. After the fix, capture another. The delta is the proof.
Enforcement: review; a perf PR carries before/after profiles, not just a claim.
Reasoning, step by step:
- Python classes default to a per-instance
__dict__, which costs memory and slows attribute access. class X: __slots__ = ("a", "b")declares fixed attributes. No__dict__. Faster access, lower memory.@dataclass(slots=True)(Python 3.10+) generates it for you.- Trade-off: no dynamic attribute assignment. For value types this is a feature.
- Multi-inheritance + slots is finicky — multi-inherit two slot-classes with overlapping slots and you'll get errors. Most value types don't multi-inherit.
Enforcement: review; hot-path classes declare __slots__ or @dataclass(slots=True).
Reasoning, step by step:
[x.value for x in items]materializes the full list — memory grows withlen(items).(x.value for x in items)is lazy — one element in memory at a time, but iterable only once.- For chained transforms, lazy beats eager when the intermediate sizes are large. For small inputs (~100 elements), eager is faster — comprehension overhead is dominated by lazy-iterator overhead.
itertoolsoperations are lazy by default:chain,groupby,islice,accumulate,pairwise.
Enforcement: review; large or streaming pipelines use generators, not materialized lists.
Reasoning, step by step:
@lru_cache(maxsize=128)memoizes a pure function. Cheap win for repeated calls with overlapping inputs.- Always bound
maxsize.maxsize=Noneis unbounded — a memory leak. - Cache key is the arguments. Mutable arguments don't hash; the cache decorator errors.
- Memoize pure functions only. Side-effecting cached calls produce stale results and confusion.
- Inspect with
func.cache_info()(hits, misses, current size).
Enforcement: review; lru_cache carries an explicit maxsize and wraps only pure functions.
Reasoning, step by step:
- Module-level
_cache: dict = {}is shared by every caller, every test, every thread. Mutation is invisible. - If state is needed: encapsulate in a class, inject the class as a dependency. Tests can substitute.
- Acceptable module-level mutables: compiled regexes (immutable after compile), connection pools (configured at import, lifecycle managed elsewhere),
functools.lru_cache(designed for this). - Anti-pattern: module-level counters incremented from inside functions. That's per-process global state with no concurrency story.
Enforcement: review; mutable state is injected, not module-level, outside the listed exceptions.
Reasoning, step by step:
s += partin a loop is O(n²) — each append allocates a new string. Use"".join(parts).- f-strings are the fastest Python formatter. Use them.
str.formatand%-style are slower;%-style is used for logging because of laziness, not speed.- For binary data,
bytes/bytearrayandb"".joinfor the same reasons.
Enforcement: review; no += string-building in loops, "".join and f-strings instead.
Reasoning, step by step:
if x in some_list:is O(n). For repeated lookups, build asetonce and reuse.dictlookup is O(1) average; constants matter for tight loops.frozensetfor immutable membership tests. Slightly faster thansetfor read-only.- Anti-pattern: scanning a list for membership inside a loop over another list. That's O(n²). Build a set first.
Enforcement: review; repeated membership tests use a set/dict built once, never in over a list.
Reasoning, step by step:
- A coroutine launch is microseconds; an
awaitis similar. Millions per second on a single thread. - Launching one task per element of a 10M-element list, each doing a microsecond of work, is wasteful — the scheduling cost dominates.
- Pattern: batch work into chunks, launch per chunk:
async def process_all(items: list[Item]) -> None: async with asyncio.TaskGroup() as tg: for chunk in batched(items, 1024): tg.create_task(process_chunk(chunk))
itertools.batched(3.12+) for the chunking.
Enforcement: review; fan-out is batched into chunks, not one task per fine-grained element.
Reasoning, step by step:
- The GIL serializes Python bytecode execution. Only one thread runs Python at a time.
- I/O releases the GIL during the wait (most stdlib I/O calls do). Threading wins for I/O parallelism.
- CPU-bound work in threads is slower than serial — context switches without parallelism.
- For CPU parallelism:
multiprocessing, or call into a native extension that releases the GIL (NumPy, Polars, much of scipy). - Python 3.13's experimental free-threaded build (PEP 703) removes the GIL but isn't yet default. Don't depend on it.
Enforcement: review; threads for I/O parallelism, multiprocessing or native extensions for CPU.
Reasoning, step by step:
- Numeric and columnar workloads in pure Python are slow.
numpy/pandas/polars/pyarroware 10-1000× faster for vectorized operations. - For custom hot paths: Cython,
mypyc, Rust extensions (pyo3), C extensions. The cost is build complexity; the win is order-of-magnitude. - Don't optimize prematurely. Measure first; reach for native extensions when profiles say so.
Enforcement: review; native extensions enter a hot path on profile evidence, not speculation.
Reasoning, step by step:
- Import time adds to cold-start. A large project can spend hundreds of milliseconds in imports before any code runs.
- Lazy imports (
import Xinside the function that uses it) defer that cost — at the price of slower first-call. importlib.util.LazyLoaderfor true lazy module loading. Useful for plugin systems and CLI tools.- For long-running services, cold-start doesn't matter much. For CLI tools and serverless, it does.
Enforcement: review; cold-start-sensitive entrypoints defer heavy imports into the call site.
Reasoning, step by step:
@dataclass(slots=True, frozen=True)is the fast and safe default for value types.- Without
slots=True, you pay for a per-instance__dict__. Significant memory across millions of instances. __hash__is generated whenfrozen=True. Hashing iterates fields each call — for very wide dataclasses on a hot path, cache the hash if you can.- Don't reach for
attrs/pydanticfor hot-path value types. They have richer features but more overhead. Stdlibdataclassis the right tool for the inner loop.
Enforcement: review; hot-path value types are stdlib dataclass with slots=True, not attrs/pydantic.
- Generators in API design: chapter 10.
- asyncio + structured concurrency: chapter 09.
- Memory profiling tools: also see performance.md cross-cutting guide.