Back to Oh My Openagent

FastAPI + SQLAlchemy 2.x async + Postgres + Pydantic v2

packages/omo-codex/plugin/skills/programming/references/python/fastapi-stack.md

4.5.129.2 KB
Original Source

FastAPI + SQLAlchemy 2.x async + Postgres + Pydantic v2

The canonical web API stack. Async end-to-end, type-safe end-to-end, OpenAPI-generated end-to-end.

Project layout

myapi/
├── pyproject.toml
├── alembic.ini
├── migrations/
│   └── env.py
├── src/
│   └── myapi/
│       ├── __init__.py
│       ├── main.py            # FastAPI app + lifespan
│       ├── config.py          # pydantic-settings
│       ├── db.py              # engine, session factory, dependency
│       ├── models.py          # SQLAlchemy declarative models
│       ├── schemas.py         # Pydantic request/response models
│       └── routers/
│           ├── __init__.py
│           └── users.py
└── tests/
    ├── conftest.py
    └── test_users.py

Dependencies

bash
uv add fastapi 'sqlalchemy[asyncio]>=2.0' asyncpg 'pydantic[email]>=2' pydantic-settings 'uvicorn[standard]' orjson
uv add --dev httpx pytest alembic

orjson is mandatory: set default_response_class=ORJSONResponse on the FastAPI app. Pydantic-typed responses bypass it (Pydantic v2's model_dump_json is already Rust-backed); raw dict / list returns are accelerated. For SSE / NDJSON streams, call orjson.dumps(...) per chunk inside StreamingResponse. See orjson-stack.md for the decision tree, flag reference, and benchmarks.

Configuration (config.py)

python
from functools import lru_cache

from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")

    database_url: PostgresDsn
    debug: bool = False
    cors_origins: list[str] = Field(default_factory=list)


@lru_cache
def get_settings() -> Settings:
    return Settings()  # type: ignore[call-arg]  # pydantic populates from env

Wait — that comment violates the no-excuse rule. Use proper field defaults instead. Real version:

python
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="MYAPI_")
    database_url: PostgresDsn
    debug: bool = False
    cors_origins: list[str] = Field(default_factory=list)

Construct via Settings(_env_file=".env") if needed in tests; in production it reads from env.

Database (db.py)

python
from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import Depends
from sqlalchemy.ext.asyncio import (
    AsyncEngine,
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from myapi.config import get_settings


def make_engine() -> AsyncEngine:
    settings = get_settings()
    return create_async_engine(
        str(settings.database_url),
        echo=settings.debug,
        pool_pre_ping=True,
    )


_engine = make_engine()
_SessionFactory = async_sessionmaker(_engine, expire_on_commit=False)


async def get_session() -> AsyncIterator[AsyncSession]:
    async with _SessionFactory() as session:
        yield session


SessionDep = Annotated[AsyncSession, Depends(get_session)]

expire_on_commit=False is essential for FastAPI - otherwise attribute access after commit triggers an implicit refresh and errors out under async.

Models (models.py)

python
from datetime import datetime, UTC
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    MappedAsDataclass,
    mapped_column,
)


class Base(MappedAsDataclass, DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    name: Mapped[str] = mapped_column(String(100))
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        init=False,
    )

MappedAsDataclass makes User(email=..., name=...) work as a real dataclass constructor. init=False excludes the auto-generated columns (id, created_at) from __init__.

Schemas (schemas.py)

python
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr


class UserCreate(BaseModel):
    email: EmailStr
    name: str


class UserRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)  # SQLAlchemy → Pydantic

    id: int
    email: EmailStr
    name: str
    created_at: datetime

Always have a separate *Create (input) and *Read (output) model. Never expose your ORM model as the API model.

Routers (routers/users.py)

python
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select

from myapi.db import SessionDep
from myapi.models import User
from myapi.schemas import UserCreate, UserRead

router = APIRouter(prefix="/users", tags=["users"])


@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(payload: UserCreate, session: SessionDep) -> User:
    user = User(email=payload.email, name=payload.name)
    session.add(user)
    await session.commit()
    await session.refresh(user)
    return user


@router.get("/{user_id}", response_model=UserRead)
async def get_user(user_id: int, session: SessionDep) -> User:
    result = await session.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if user is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
    return user


@router.get("", response_model=list[UserRead])
async def list_users(session: SessionDep, limit: int = 100) -> list[User]:
    result = await session.execute(select(User).limit(limit))
    return list(result.scalars().all())

Application (main.py)

python
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator

from fastapi import FastAPI

from myapi.config import get_settings
from myapi.routers import users


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    # Startup: warm up engine pool, run migrations check, etc.
    yield
    # Shutdown: close engine
    from myapi.db import _engine
    await _engine.dispose()


def create_app() -> FastAPI:
    settings = get_settings()
    app = FastAPI(
        title="My API",
        debug=settings.debug,
        lifespan=lifespan,
    )
    app.include_router(users.router)
    return app


app = create_app()

Run with:

bash
uv run uvicorn myapi.main:app --host 0.0.0.0 --port 8000 --reload

Migrations (Alembic + async)

bash
uv run alembic init -t async migrations

In migrations/env.py replace the target_metadata line:

python
from myapi.models import Base
target_metadata = Base.metadata

Set sqlalchemy.url in alembic.ini to your async URL or override via env.py:

python
from myapi.config import get_settings
config.set_main_option("sqlalchemy.url", str(get_settings().database_url))

Generate and apply:

bash
uv run alembic revision --autogenerate -m "create users"
uv run alembic upgrade head

Tests (tests/test_users.py)

python
import pytest
from httpx import ASGITransport, AsyncClient

from myapi.main import app


@pytest.mark.anyio
async def test_create_and_get_user() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        create_response = await client.post(
            "/users",
            json={"email": "[email protected]", "name": "Alice"},
        )
        assert create_response.status_code == 201
        user_id = create_response.json()["id"]

        get_response = await client.get(f"/users/{user_id}")
        assert get_response.status_code == 200
        assert get_response.json()["email"] == "[email protected]"

For database-backed tests, run a Postgres container in CI (testcontainers-python or docker-compose) and apply migrations against a test schema. SQLite-as-test-db breaks once you use Postgres-specific types (JSONB, tsvector, arrays).

Common pitfalls

PitfallFix
MissingGreenlet exception when accessing relationships after commitexpire_on_commit=False on the session factory
Connection pool exhausted under loadSet pool_size, max_overflow in create_async_engine
Pydantic v1 syntax (from pydantic import ...; class X(BaseModel): class Config: orm_mode = True)v2 uses model_config = ConfigDict(from_attributes=True)
Returning ORM objects without response_modelFastAPI serialises with from_attributes=True automatically; declare response_model so OpenAPI is correct
await session.execute(...) returning SequenceWrap with list(result.scalars().all()) to satisfy strict types
func.now() returning naive datetimeUse DateTime(timezone=True) and created_at: Mapped[datetime] with UTC-aware default

Sources