| name | python-patterns |
| description | Python idioms — type hints, dataclasses, async/await, generators, pytest, common pitfalls. Activate when writing or reviewing Python code. |
| origin | FlowDeck |
Python Patterns Skill
Idiomatic Python for production-grade code. Covers modern Python 3.10+ practices.
When to Activate
Activate when:
- Writing new Python modules or packages
- Reviewing Python code for correctness and idiom
- Deciding between data modeling approaches (dataclass vs TypedDict vs Pydantic)
- Designing async services or background workers
- Setting up testing infrastructure
Type Hints
Python's type system (PEP 484, 526, 544) makes code self-documenting and enables static analysis with mypy or pyright.
Basic Annotations
count: int = 0
names: list[str] = []
mapping: dict[str, int] = {}
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}!\n" * times).rstrip()
def find_user(user_id: int) -> "User | None":
...
type UserId = int
UserId = NewType("UserId", int)
Protocols (PEP 544) — Structural Subtyping
Prefer Protocol over ABC when you don't control the implementor.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict[str, object]: ...
def save(obj: Serializable) -> None:
data = obj.to_dict()
...
Generics
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
Data Modeling: Dataclass vs TypedDict vs Pydantic
Choose based on where the data lives and what guarantees you need.
Dataclass — in-memory objects with behavior
from dataclasses import dataclass, field
@dataclass
class Order:
id: str
items: list[str] = field(default_factory=list)
total: float = 0.0
def add_item(self, item: str, price: float) -> None:
self.items.append(item)
self.total += price
@dataclass(frozen=True)
class Money:
amount: int
currency: str = "USD"
TypedDict — typed dictionaries, no runtime overhead
Best for function signatures that accept/return dict-shaped data (JSON responses, kwargs).
from typing import TypedDict, NotRequired
class UserPayload(TypedDict):
id: str
email: str
name: NotRequired[str]
def process(payload: UserPayload) -> None:
print(payload["id"])
Pydantic — validation at the boundary
Use at I/O boundaries: API request bodies, config files, deserialized data.
from pydantic import BaseModel, Field, field_validator
class CreateUserRequest(BaseModel):
email: str
age: int = Field(gt=0, lt=150)
name: str = Field(min_length=1, max_length=100)
@field_validator("email")
@classmethod
def email_must_have_at(cls, v: str) -> str:
if "@" not in v:
raise ValueError("not a valid email")
return v.lower()
req = CreateUserRequest(email="ALICE@EXAMPLE.COM", age=30, name="Alice")
Decision rule: dataclass for domain objects with behavior → TypedDict for dicts that stay dicts → Pydantic for external input validation.
Context Managers
The with statement guarantees cleanup whether or not an exception occurs.
Using contextlib
from contextlib import contextmanager, asynccontextmanager
@contextmanager
def managed_connection(dsn: str):
conn = connect(dsn)
try:
yield conn
finally:
conn.close()
with managed_connection("postgresql://...") as conn:
conn.execute("SELECT 1")
Class-Based Context Managers
class Timer:
def __enter__(self) -> "Timer":
import time
self._start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
self.elapsed = time.perf_counter() - self._start
return False
with Timer() as t:
expensive_operation()
print(f"Took {t.elapsed:.3f}s")
Suppressing Exceptions
from contextlib import suppress
with suppress(FileNotFoundError):
Path("optional.txt").unlink()
Generator and Iterator Patterns
Generators yield values lazily — use them for large sequences or pipelines.
Basic Generator
def read_chunks(path: str, size: int = 8192):
with open(path, "rb") as f:
while chunk := f.read(size):
yield chunk
for chunk in read_chunks("large.bin"):
process(chunk)
Generator Pipelines
def parse_lines(lines):
for line in lines:
yield line.strip()
def filter_comments(lines):
for line in lines:
if not line.startswith("#"):
yield line
def process_file(path: str):
raw = open(path)
stripped = parse_lines(raw)
meaningful = filter_comments(stripped)
return meaningful
itertools for Composition
import itertools
flat = list(itertools.chain.from_iterable([[1, 2], [3, 4]]))
def windows(iterable, n):
iters = itertools.tee(iterable, n)
for i, it in enumerate(iters):
next(itertools.islice(it, i, i), None)
return zip(*iters)
Asyncio Patterns
Use async/await for I/O-bound concurrency. Don't use it for CPU-bound work (use multiprocessing instead).
Async HTTP: httpx vs aiohttp
import httpx
async def fetch_user(user_id: int) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://api.example.com/users/{user_id}")
resp.raise_for_status()
return resp.json()
import aiohttp
async def stream_large_file(url: str):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
async for chunk in resp.content.iter_chunked(8192):
process(chunk)
Concurrent Tasks
import asyncio
async def fetch_all(ids: list[int]) -> list[dict]:
async with httpx.AsyncClient() as client:
tasks = [fetch_one(client, id) for id in ids]
return await asyncio.gather(*tasks)
async def with_timeout(coro, seconds: float):
try:
return await asyncio.wait_for(coro, timeout=seconds)
except asyncio.TimeoutError:
raise TimeoutError(f"operation exceeded {seconds}s")
Event Loop Best Practices
if __name__ == "__main__":
asyncio.run(main())
async def library_function() -> None:
loop = asyncio.get_running_loop()
...
Comprehensions — When to Use vs Loops
Comprehensions communicate intent at a glance. Loops are clearer for side effects.
active_names = [u.name for u in users if u.is_active]
index = {u.id: u for u in users}
unique_tags = {tag for post in posts for tag in post.tags}
total = sum(item.price for item in cart)
[print(x) for x in items]
for x in items:
print(x)
matrix = [[col * row for col in range(5)] for row in range(5)]
Exception Hierarchy and Custom Exceptions
Define a base exception per module/package and branch from it.
class AppError(Exception):
"""Base for all application errors."""
class NotFoundError(AppError):
def __init__(self, resource: str, id: object) -> None:
super().__init__(f"{resource} {id!r} not found")
self.resource = resource
self.id = id
class ValidationError(AppError):
def __init__(self, field: str, message: str) -> None:
super().__init__(f"{field}: {message}")
self.field = field
try:
get_user(user_id)
except NotFoundError as exc:
return 404, {"error": str(exc)}
except AppError as exc:
logger.exception("unexpected app error")
return 500, {"error": "internal error"}
try:
result = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValidationError("body", "invalid JSON") from exc
Dependency Management
pyproject.toml (PEP 518/621)
[project]
name = "my-service"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = ["pytest>=8", "mypy", "ruff"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
uv — fast package manager
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
uv add httpx
uv lock
poetry — alternative with integrated lock file
poetry new my-project
poetry add httpx pydantic
poetry add --group dev pytest mypy
poetry run pytest
Testing with pytest
Fixtures
import pytest
from myapp.db import Database
@pytest.fixture
def db():
database = Database(":memory:")
database.migrate()
yield database
database.close()
@pytest.fixture
def user(db):
return db.create_user(email="alice@example.com", name="Alice")
def test_user_lookup(db, user):
found = db.get_user(user.id)
assert found.email == user.email
Parametrize
@pytest.mark.parametrize("email,valid", [
("alice@example.com", True),
("no-at-sign", False),
("@nodomain", False),
("spaces @x.com", False),
])
def test_email_validation(email: str, valid: bool) -> None:
assert validate_email(email) == valid
Mocking
from unittest.mock import AsyncMock, MagicMock, patch
def test_send_notification(mocker):
mock_send = mocker.patch("myapp.email.send_email")
notify_user(user_id=1)
mock_send.assert_called_once_with(
to="alice@example.com",
subject="Welcome",
)
async def test_async_fetch():
with patch("myapp.client.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.get.return_value = AsyncMock(
status_code=200,
json=lambda: {"id": 1},
)
result = await fetch_user(1)
assert result["id"] == 1
Common Pitfalls
Mutable Default Arguments
def append_to(item, lst=[]):
lst.append(item)
return lst
def append_to(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
Late Binding in Closures
funcs = [lambda: i for i in range(5)]
funcs = [lambda i=i: i for i in range(5)]
The GIL and CPU-Bound Work
from concurrent.futures import ProcessPoolExecutor
def cpu_bound(n: int) -> int:
return sum(range(n))
with ProcessPoolExecutor() as pool:
results = list(pool.map(cpu_bound, [10**7, 10**7, 10**7]))
Circular Imports
def get_thing():
from myapp.other_module import Thing
return Thing()
Related Skills
- backend-patterns
- django-patterns
- python-testing