Python Architecture Standards
Targets Python 3.14. See STACK.md for pinned dependency versions.
1. Typing & Domain Safety
- Modern syntax: Built-in generics (
list[str],dict[K, V],X | None). Never the legacytyping.List/typing.Optional. - Deferred annotations (PEP 649, 3.14): Annotations are no longer eagerly evaluated — forward references no longer need quotes (
def f(arg: NotYetDefined)works). Inspect viaannotationlib.get_annotations(), not__annotations__directly. - Domain types:
typing.NewTypeto separate distinct concepts (UserIdvsOrderId). - Enums: Default to
Enum(with__str__overridden) for closed sets of domain states — members are distinct identities, not interchangeable with raw primitives, which catches accidental comparisons against arbitrary strings/ints. Reach forStrEnum(3.11+) when members must interoperate directly with strings — JSON payloads, query params, f-strings — without a.valuecall at every site. Reach forIntEnumwhen members must support arithmetic or ordering against plain integers (HTTP status codes, priority levels, wire values from an external system). Both tradeEnum's identity-safety for primitive compatibility — reach for them only when that interop is a real requirement, not by default. - Constraints:
Literalfor a narrow, function-local set of string flags that doesn't warrant a fullEnum. - Structured payloads:
TypedDictoverdict[str, Any]for known-shape mappings (see §3 for the broader dict/tuple-avoidance principle). - Subclassing safety:
typing.overridedecorator (3.12+) on every overriding method — mypy flags broken overrides.
2. Generators & Iterators
- Return
Iterator[T]/ write a generator when: the sequence is large or unbounded, the consumer might short-circuit (break, earlyreturn), or it's backed by a cursor (DB pagination, paginated HTTP APIs, file streaming). Laziness avoids materializing the whole sequence in memory. - Return
list[T]when: the result is small, bounded, and the caller almost always consumes the whole thing — don't wrap it in a generator just to look idiomatic. - Async generators:
AsyncIterator[T]/async def ... yieldfor streaming I/O (paginated API clients, chunked reads) — pairs withasync for. See §5 for asyncio discipline. itertools: default toolkit for lazy composition (chain,islice,groupby,pairwise) over manual index bookkeeping.- Anti-pattern: collecting a generator into a
listimmediately after producing it (list(gen())) just to satisfy a type checker — return the concrete type the caller actually needs instead of round-tripping through both.
3. Data Structures & Memory
- Immutability: Default to
@dataclass(slots=True, frozen=True)for DTOs and value objects. - Mutable defaults: Never use a mutable literal (
[],{},set()) as a function parameter default or a bare dataclass field default — it's shared across every call/instance. UseNoneand assign inside the function body, orfield(default_factory=list)on dataclasses. Enforced by ruff'sB006/B008(bugbear, see §11). - Pydantic vs dataclass boundary: Pydantic only at application boundaries (API request/response, DB row parsing, config). Standard dataclasses for core domain logic — keeps the domain free of validation-framework coupling.
- Typed models over dicts/tuples: Prefer a
dataclass/NamedTuple/TypedDict(see §1) todict[str, Any]or a rawtuplefor anything with a stable shape — attribute access catches typos and missing fields that dict keys and tuple indices can't. Reserve bare dicts/tuples for genuinely dynamic or anonymous data (arbitrary JSON blobs,zip()output consumed immediately, coordinate pairs). - Ordering:
@dataclass(order=True)for value objects that need comparison operators — avoid hand-rolled__lt__/__gt__/__le__/__ge__chains. For one-off custom sort keys, pass a plain function tosorted(key=...)rather than implementing a full ordering protocol. - Memory:
__slots__(explicit or viadataclass(slots=True)) on high-volume instances.
4. Interfaces & DI
- Protocols:
typing.Protocol(structural typing) over deepabc.ABCinheritance. Define protocols where consumed. - DI: Pass dependencies into
__init__. Never instantiate external clients inside a class. - State: No globals.
contextvarsonly when request-scoped state is unavoidable.
5. Concurrency & Resources
- Asyncio discipline: Never block the event loop. Offload sync I/O or CPU work via
asyncio.to_thread(). - Task groups:
asyncio.TaskGroupfor concurrent coroutines — handles cancellation and exception aggregation properly. Avoid bareasyncio.gather. - Multiple interpreters (PEP 734, 3.14): Use
concurrent.interpretersfor CPU-bound parallelism — true multi-core withoutmultiprocessing's overhead, no GIL contention. - Introspection: Debug live async apps with
python -m asyncio ps <PID>/pstree <PID>(3.14). - Free-threaded builds (PEP 703): Be aware of the no-GIL variant. Design hot paths to avoid shared mutable state regardless of GIL presence.
- Resources: Wrap I/O in
with/async with. Usecontextlibfor compositions.
6. Packages & Imports
- Imports: Three groups separated by blank lines — stdlib, third-party, local. Prefer absolute imports.
__init__.py: Minimal. Use__all__ = [...]to declare the public API explicitly.- Bundled resources: Use
importlib.resources.files(__package__).joinpath("...").read_text()for embedded files (SQL, templates). Survives wheel and zipapp packaging — never use__file__-relative paths for shipped assets.
7. Errors & Testing
- Exceptions: A base custom exception per module. Always chain (
raise NewError(...) from err). Never bareexcept:. - Exception groups (PEP 654, 3.11+):
asyncio.TaskGroup(§5) raisesExceptionGroupwhen child tasks fail — catch withexcept*(e.g.except* TimeoutError:), never a bareexcept Exception, or concurrent failures from separate tasks collapse into one swallowed exception. - Bracketless except (PEP 758, 3.14):
except TimeoutError, ConnectionRefusedError:is now valid without parens when noasclause. - Finally hazards (PEP 765, 3.14):
return/break/continueinsidefinallynow emits SyntaxWarning — refactor it out. - Iterables:
map(strict=True)(3.14) when consuming parallel iterables, matchingzip(strict=True). - Testing:
pytest 9withconftest.pyfixtures. Never the legacyunittestmodule.pytest-asynciofor async tests. - Integration tests against Docker dependencies:
testcontainers-python— spins up real Postgres/Redis/Kafka/etc. containers per test run instead of mocking the driver or relying on a shared dev instance. Mark these with a dedicatedpytestmarker (e.g.@pytest.mark.integration) and exclude by default sopyteststays fast.
8. Documentation
- Docstrings: Google style (Args, Returns, Raises).
- DRY: Don't repeat type info already in hints.
- Focus: Explain why (domain rules, edge cases), not what.
9. Stdlib defaults
Prefer stdlib when it covers the use case.
pathlib.Pathfor all paths — neveros.pathstrings. New in 3.14:Path.copy(),Path.move(),Path.copy_into(),Path.move_into()for recursive operations.compression.zstd(3.14) overgzip/bz2for new payloads —gzip/bz2/lzma/zlibare now re-exported undercompression.*.importlib.resourcesfor shipped files (see §6).contextlibfor resource lifecycle composition.dataclassesfor data containers (see §3).
10. Database access — SQL files + importlib.resources
Recommended pattern, not mandatory. Mirrors the Go sqlx + //go:embed philosophy: raw SQL in .sql files, loaded once at module import, executed via psycopg 3. No ORM by default — keeps queries auditable in git and gives editors full SQL syntax highlighting and linting.
from importlib.resources import files
import psycopg
from psycopg.rows import class_row
GET_USER_BY_ID = files(__package__).joinpath("queries/get_user_by_id.sql").read_text()
class UserRepo:
def __init__(self, conn: psycopg.AsyncConnection) -> None:
self._conn = conn
async def get_by_id(self, user_id: int) -> User | None:
async with self._conn.cursor(row_factory=class_row(User)) as cur:
await cur.execute(GET_USER_BY_ID, (user_id,))
return await cur.fetchone()
Layout:
src/myapp/userrepo/
├── __init__.py
├── repo.py
└── queries/
├── get_user_by_id.sql
├── insert_user.sql
└── list_users.sql
- Driver:
psycopg 3— sync + async, server-side cursors, COPY, prepared statements. - Migrations:
alembic— versioned, works with raw SQL (no SQLAlchemy ORM required). - Dynamic queries: Compose
.sqlfragments in Python; never concatenate user input — bind parameters. - When an ORM is genuinely needed: SQLAlchemy 2.x (Core or ORM). Record the decision in an ADR.
11. Tooling
- Environment + packaging:
uv— replacespip,pip-tools,virtualenv,pyenv. Single binary, fast. Commituv.lock; runuv sync --frozenin CI. - Lint + format:
ruff— replacesblack,isort,flake8,pyupgrade. One config, one tool. Drop-in template:assets/ruff.toml— copy to your project root asruff.toml(or fold intopyproject.tomlunder[tool.ruff]) and setknown-first-partyto your package name. Runruff checkandruff format --checkon every commit and in CI; treat warnings as errors.- Correctness & bugs:
F(pyflakes),B(bugbear, incl.B006/B008mutable defaults — see §3, andB904exception chaining — see §7),ASYNC(asyncio anti-patterns — see §5),RUF(ruff-specific, e.g.RUF012mutable class defaults). - Security:
S(flake8-bandit) — SQL/command injection, hardcoded secrets, weak crypto. Test files relaxS101/S105-S107viaper-file-ignoressince asserts and fixture creds are expected there. - Typing discipline:
ANN(typed signatures — the mypy--strictbaseline),TC(TYPE_CHECKINGguards, withruntime-evaluated-base-classescarved out for Pydantic/Settings — see §3),PYI(stub-file quality). - Modernization:
UP(pyupgrade — see §1),FA(future annotations),FURB(refurb),PERF(perflint). - Style & structure:
I(isort),N(pep8-naming),C4(comprehensions),SIM(simplify),RET/RSE(control-flow and raise style),PIE,PTH(pathlib overos.path— see §9),ISC,TID,A(no builtin shadowing). - Complexity & size:
PL(pylint subset), thresholds tuned in[lint.pylint](max-args = 7,max-branches = 12,max-returns = 6,max-statements = 50) — split functions instead of suppressing. - Test style:
PT(flake8-pytest-style) — fixture/mark parenthesis conventions tuned in[lint.flake8-pytest-style](see §7). - Docs & dead code:
D(pydocstyle, Google convention — see §8),ERA(eradicate — no commented-out code). - Test-file relaxations:
per-file-ignoresdropsANN,D,S101,PLR2004,SLF001,INP001undertests/**— type hints and docstrings on test functions add noise without value; asserts, magic numbers, and private-member access are the point of a test. - Auto-fix guardrails:
fixable = ["ALL"], butunfixableexcludesERA,F401,F841— never let--fixsilently delete commented-out code or unused imports/locals; those need a human decision.
- Correctness & bugs:
- Type checking:
mypy --strictas the baseline — noAny-by-default escape hatches. Drop-in template:assets/mypy.ini— copy to your project root (or fold[mypy]intopyproject.toml's[tool.mypy]) and setpackagesto your package name.- Beyond
--strict:warn_unreachable,warn_redundant_casts,warn_unused_ignores,strict_equality,extra_checks— catch dead branches, stale# type: ignorecomments, and cross-type equality bugs that--strictalone misses. - Per-module overrides: relax
disallow_untyped_defsundertests.*(fixtures and@pytest.mark.parametrizeroutinely defeat full inference);ignore_errorsundermigrations.*(Alembic-generated, not hand-typed); scopeignore_missing_importsto named untyped dependencies instead of a blanket override, which would silently swallow first-party import typos too. - CI parity: run
ruff check,ruff format --check, andmypyas three separate, mandatory CI gates — a formatting fix should never ride along with a type fix in the same commit.
- Beyond
- Test:
pytest 9+pytest-asynciofor async paths.pytest-covfor coverage gating in CI.
Canonical libraries
See STACK.md for the full pinned list — pydantic, pydantic-settings, fastapi, uvicorn, httpx, pytest, pytest-asyncio, mypy, ruff, uv, typer, psycopg, alembic.