Skip to content

โ† Blog

Pydantic + SQLAlchemy: The Real Stack

FastAPI gets the credit. Pydantic v2 and SQLAlchemy 2.0 do the heavy lifting.

FastAPI gets the credit. Pydantic v2 and SQLAlchemy 2.0 do the heavy lifting.

FastAPI gets the credit. Pydantic v2 and SQLAlchemy 2.0 do the heavy lifting.

The framework is a thin ASGI wrapper around those two libraries. If you understand them, you can move between frameworks without losing the stack.

Pydantic v2 โ€” the contract layer

1. `BaseModel` with strict config: `extra="forbid", strict=True, str_strip_whitespace=True`. Unknown fields and wrong types are rejected at the door, not deep inside a handler.
2. `Annotated[str, Field(min_length=1, max_length=255)]` reusable types declared once and shared across `BookCreate`, `BookReplace`, `BookPatch`. The wire format stops being ad hoc.
3. Separate input and output schemas. `BookCreate` rejects unknown fields. `BookOut` exposes `id`, `created_at`, `updated_at`, `status` โ€” fields the client must never set on input.
4. `ConfigDict(from_attributes=True)` on `BookOut`. The repository returns an ORM `Book`; the router calls `BookOut.model_validate(book)` and serialisation is one line.
5. OpenAPI is a free side-effect. Every schema becomes a component in `openapi.json`. Swagger UI at `/docs` is always in sync with the code.

SQLAlchemy 2.0 async โ€” the data layer

1. Typed declarative mapping: `Mapped[int] = mapped_column(Integer, primary_key=True)`. IDE auto-complete, mypy-friendly, no more string-based column references.
2. `AsyncSession` + asyncpg driver. `select(Book).order_by(Book.id)` composed once, executed with `await session.scalars(...)`. The 1.x `query()` API is gone โ€” good riddance.
3. The lifecycle that bites you first: returning an ORM object from the session context detaches it. Accessing lazy-loaded relationships afterwards explodes. For scalars it works; for anything else, eager-load with `selectinload` or convert to a Pydantic model inside the session scope.
4. One engine, disposed in `lifespan`. The pool stays warm across requests; on shutdown, `await engine.dispose()` closes connections cleanly. No leaked sockets.
5. Alembic migrations work the same as sync. The async engine only matters at runtime; offline migration generation is unchanged.

When async actually helps:
endpoints fanning out multiple I/O calls (DB + external API + cache), high concurrency under one process, streaming and WebSocket.

When async is theatre:
low-throughput internal CRUD where the latency floor is dominated by one round-trip; CPU-bound work that blocks the event loop; small hobby projects where the ergonomic cost is not paid back.

Pick the libraries first. The framework follows.

What's the gotcha you've hit most often with async SQLAlchemy in production?

P.S. Code: https://github.com/bilouro/FastAPIProject

P.S. New tech post every Wednesday.

#FastAPI #SQLAlchemy #PydanticV2