| name | python |
| description | Use when writing or debugging non-trivial Python — async pitfalls, type system patterns, dataclass vs Pydantic decisions, decorator design, generator efficiency, or language-specific idioms like structural pattern matching and slots. |
Python — Advanced Patterns
Language-level patterns for writing correct, readable, performant Python.
When to Activate
- Choosing between dataclasses, TypedDict, NamedTuple, or Pydantic models
- Designing type annotations with generics, Protocols, or
TypeVar
- Writing or debugging async code (
async/await, event loops, asyncio)
- Building decorators, context managers, or generators
- Using
itertools, functools, or comprehensions effectively
- Applying structural pattern matching (
match/case)
- Performance micro-optimisations (
__slots__, lru_cache, generators vs lists)
Type Hints
Built-in generics (Python 3.10+)
def process(items: list[str]) -> dict[str, int]: ...
def fetch(ids: set[int]) -> tuple[str, ...]: ...
def map_fn(data: dict[str, list[int]]) -> None: ...
def parse(value: str | int | None) -> str: ...
type UserId = str
UserId = NewType("UserId", str)
TypeVar and Generics
from typing import TypeVar, Generic
T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")
def first(items: list[T]) -> T | None:
return items[0] if items else None
class Repository(Generic[T]):
async def get(self, id: str) -> T | None: ...
async def save(self, entity: T) -> T: ...
class UserRepository(Repository[User]): ...
Protocol — structural subtyping (duck typing with types)
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict: ...
@classmethod
def from_dict(cls, data: dict) -> "Serializable": ...
def cleanup(resource: Closeable) -> None:
resource.close()
Annotated — attach metadata to types
from typing import Annotated
from pydantic import Field
PositiveInt = Annotated[int, Field(gt=0)]
EmailStr = Annotated[str, Field(pattern=r".+@.+")]
UserId = Annotated[str, Field(min_length=36, max_length=36)]
class User(BaseModel):
id: UserId
age: PositiveInt
email: EmailStr
Literal and TypeGuard
from typing import Literal, TypeGuard
Status = Literal["active", "inactive", "banned"]
def is_active(status: Status) -> TypeGuard[Literal["active"]]:
return status == "active"
from typing import TypedDict
class UserDict(TypedDict):
id: str
name: str
email: str
class PartialUserDict(TypedDict, total=False):
name: str
email: str
Dataclasses vs Pydantic vs TypedDict
| Dataclass | Pydantic | TypedDict | NamedTuple |
|---|
| Runtime validation | ❌ | ✅ | ❌ | ❌ |
| Immutable option | frozen=True | frozen=True | ❌ | ✅ (always) |
| JSON serialization | manual | .model_dump() | manual | manual |
| Inheritance | ✅ | ✅ | limited | ❌ |
| Performance | fastest | moderate | dict | fast |
| Use when | internal data transfer | API schemas, config | typed dict hints | simple immutable tuples |
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
tags: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Color:
r: int; g: int; b: int
@dataclass(slots=True)
class FastModel:
name: str
value: int
Async / Await
Pitfalls
async def handler():
time.sleep(1)
async def handler():
await asyncio.sleep(1)
async def read_file():
return open("file.txt").read()
import aiofiles
async def read_file():
async with aiofiles.open("file.txt") as f:
return await f.read()
import asyncio
result = await asyncio.get_event_loop().run_in_executor(None, blocking_function, arg)
Concurrency patterns
import asyncio
results = await asyncio.gather(
fetch_user(id),
fetch_orders(id),
fetch_profile(id),
)
user, orders, profile = results
results = await asyncio.gather(fetch_a(), fetch_b(), return_exceptions=True)
for r in results:
if isinstance(r, Exception):
handle_error(r)
async with asyncio.TaskGroup() as tg:
task_a = tg.create_task(fetch_a())
task_b = tg.create_task(fetch_b())
try:
result = await asyncio.wait_for(slow_operation(), timeout=5.0)
except asyncio.TimeoutError:
handle_timeout()
sem = asyncio.Semaphore(10)
async def rate_limited_fetch(url):
async with sem:
return await httpx.get(url)
Async generators and context managers
async def paginate(url: str):
page = 1
while True:
data = await fetch(f"{url}?page={page}")
if not data:
break
yield data
page += 1
async for batch in paginate("/api/items"):
process(batch)
class AsyncDB:
async def __aenter__(self):
self.conn = await connect()
return self.conn
async def __aexit__(self, *args):
await self.conn.close()
async with AsyncDB() as conn:
await conn.execute("SELECT 1")
Context Managers
from contextlib import contextmanager, asynccontextmanager, suppress
@contextmanager
def timer(label: str):
start = time.perf_counter()
try:
yield
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timer("query"):
result = db.execute(query)
@asynccontextmanager
async def db_transaction(session):
async with session.begin():
try:
yield session
except Exception:
await session.rollback()
raise
with suppress(FileNotFoundError):
os.remove("tmp.txt")
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(f)) for f in file_list]
process(files)
Decorators
from functools import wraps
import time
def retry(max_attempts: int = 3, delay: float = 1.0):
def decorator(fn):
@wraps(fn)
async def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return await fn(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
await asyncio.sleep(delay * (attempt + 1))
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
async def fetch_data(url: str) -> dict: ...
class RateLimit:
def __init__():
.calls = calls
.period = period
.timestamps: [] = []
():
():
now = time.time()
.timestamps = [t t .timestamps now - t < .period]
(.timestamps) >= .calls:
Exception()
.timestamps.append(now)
fn(*args, **kwargs)
wrapper
Generators and itertools
import itertools
def read_chunks(path: str, size: int = 4096):
with open(path, "rb") as f:
while chunk := f.read(size):
yield chunk
total = sum(x ** 2 for x in range(1_000_000))
list(itertools.islice(range(100), 10))
list(itertools.chain([1,2], [3,4], [5,6]))
list(itertools.batched([1..9], 3))
list(itertools.groupby(sorted_items, key=lambda x: x.category))
list(itertools.takewhile(lambda x: x < 5, items))
list(itertools.dropwhile(lambda x: x < 5, items))
list(itertools.pairwise([1,2,,]))
functools lru_cache, cached_property, reduce, partial
() -> :
n n < fibonacci(n-) + fibonacci(n-)
:
() -> [Rule]:
parse_rules(.raw)
double = partial(operator.mul, )
Structural Pattern Matching (Python 3.10+)
def handle_event(event: dict):
match event:
case {"type": "user_created", "data": {"id": user_id, "email": email}}:
create_user(user_id, email)
case {"type": "order_placed", "data": {"total": total}} if total > 1000:
flag_high_value_order(event)
case {"type": str(t)} if t.startswith("payment_"):
handle_payment(event)
case _:
log_unknown(event)
def process(value):
match value:
case int(n) if n > 0: return f"positive int: {n}"
case str(s): return f"string: {s}"
case [*items]: return f"list of {len(items)}"
case {"key": v}: return f"dict with key: {v}"
:
__slots__
Reduces memory per instance by ~40-60% for classes with many instances. Prevents arbitrary attribute assignment.
class Point:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
class Point3D(Point):
__slots__ = ("z",)
Use slots=True on dataclasses: @dataclass(slots=True).
Common Gotchas
def bad(items=[]): items.append(1)
def good(items=None): items = items or []
fns = [lambda x, i=i: x + i for i in range(3)]
a = 256; b = 256; a is b
a = 257; b = 257; a is b
while chunk := file.read(8192):
process(chunk)
if m := re.search(pattern, text):
print(m.group(0))
try:
result = parse(data)
except ValueError as e:
raise ServiceError("Parse failed") from e
x = 42
print(f"{x=}")
Performance Tips
| Technique | When to use |
|---|
__slots__ | Many instances of the same class in memory |
@lru_cache | Pure functions called repeatedly with same args |
| Generator over list | Large sequences you iterate once |
collections.deque | Frequent append/pop from both ends |
set lookup | x in large_collection — O(1) vs O(n) for list |
str.join | Building strings in a loop — never += in a loop |
local variable | Hoist self.attr to local in tight loops |
asyncio.gather | Independent async calls — run concurrently |
Red Flags
- Mutable default arguments (
def f(items=[])) — the default list is created once at definition time and shared across all calls; use None as the default and initialize inside the function body
- Bare
except: or except Exception: — catching all exceptions hides bugs and swallows KeyboardInterrupt; catch the narrowest specific exception type you actually expect and handle
asyncio.run() inside an already-running event loop — calling asyncio.run() from within an async context (FastAPI, Jupyter) raises RuntimeError; use await directly or loop.run_until_complete()
- Threads for CPU-bound work — Python's GIL prevents true thread parallelism for CPU tasks; use
multiprocessing or ProcessPoolExecutor for CPU-bound parallelism
from module import * in __init__.py — star imports pollute the namespace and make name origins untraceable; always import explicitly
@dataclass fields with mutable defaults — field: list = [] shares the same list object across all instances; use field(default_factory=list) for mutable defaults
is to compare values — x is 1 or x is "hello" relies on CPython interning that is not guaranteed across Python versions; use == for value comparison and is only for None, True, False
Checklist