| name | python-fastapi-patterns |
| description | Deep reference for Python/FastAPI patterns -- async SQLAlchemy, Pydantic v2, service layer, dependency injection, error handling, testing, background jobs. Covers non-obvious gotchas and production patterns. |
Python / FastAPI / SQLAlchemy Patterns
Production patterns for FastAPI applications with async SQLAlchemy 2.0 and Pydantic v2. Focused on things that are non-obvious or that people commonly get wrong.
Table of Contents
- Dependency Injection
- Async SQLAlchemy Session Management
- SQLAlchemy ORM Patterns
- Pydantic v2 Patterns
- Service Layer Architecture
- Error Handling
- Authentication & Authorization
- Pagination
- Background Jobs
- Testing
- Database Migrations
- Configuration
- Concurrency & Locking
- Quick Reference
Dependency Injection
Extract Ownership Checks into Dependencies
Every route that accesses a user-owned resource repeats the same lookup + ownership check. Extract it.
@router.get("/decks/{deck_id}")
async def get_deck(
deck_id: UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
deck = await repo.get_by_id(deck_id)
if not deck or deck.user_id != user.id:
raise NotFoundError("Deck")
return deck
@router.put("/decks/{deck_id}")
async def update_deck(
deck_id: UUID,
body: DeckUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
deck = await repo.get_by_id(deck_id)
if not deck or deck.user_id != user.id:
raise NotFoundError("Deck")
...
async def get_owned_deck(
deck_id: UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Deck:
repo = DeckRepository(db)
deck = await repo.get_by_id(deck_id)
if not deck or deck.user_id != user.id:
raise NotFoundError("Deck")
return deck
@router.get("/decks/{deck_id}")
async def get_deck(deck: Deck = Depends(get_owned_deck)):
return deck
@router.put("/decks/{deck_id}")
async def update_deck(body: DeckUpdate, deck: Deck = Depends(get_owned_deck)):
...
FastAPI caches dependency results within a single request. If get_current_user is used by multiple chained dependencies, it only runs once.
Use Annotated Types for Cleaner Signatures
from typing import Annotated
DbSession = Annotated[AsyncSession, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/decks")
async def list_decks(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
pagination: PaginationParams = Depends(),
): ...
@router.get("/decks")
async def list_decks(
user: CurrentUser,
db: DbSession,
pagination: PaginationParams = Depends(),
): ...
Router-Level Dependencies Over Middleware
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
public_routes = ["/", "/login", "/health"]
if request.url.path not in public_routes:
...
return await call_next(request)
protected = APIRouter(dependencies=[Depends(get_current_user)])
public = APIRouter()
app.include_router(protected, prefix="/api")
app.include_router(public)
When NOT to Use Depends
async def calculate_tax(amount: float) -> float:
return amount * 0.1
@router.post("/order")
async def create_order(tax: float = Depends(calculate_tax)): ...
@router.post("/order")
async def create_order(amount: float, db: DbSession):
tax = amount * 0.1
Use Depends for: database sessions, auth, rate limiters, request-scoped config.
Don't use Depends for: pure functions, one-off logic, values you'd cache with @lru_cache.
Async SQLAlchemy Session Management
expire_on_commit=False Is Mandatory for Async
This is the single most common source of production crashes with async SQLAlchemy.
async_session_factory = async_sessionmaker(engine, class_=AsyncSession)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
Eager Loading Is Not Optional
In sync SQLAlchemy, lazy loading "just works" -- it fires a query when you access a relationship. In async, lazy loading is a synchronous I/O call that crashes.
async def get_deck_with_problems(db: AsyncSession, deck_id: UUID) -> Deck:
result = await db.execute(select(Deck).where(Deck.id == deck_id))
deck = result.scalar_one()
problems = deck.problems
return deck
from sqlalchemy.orm import selectinload, joinedload
async def get_deck_with_problems(db: AsyncSession, deck_id: UUID) -> Deck:
result = await db.execute(
select(Deck)
.where(Deck.id == deck_id)
.options(selectinload(Deck.problems))
)
return result.scalar_one()
Which eager loading strategy to use:
| Strategy | Best for | How it works |
|---|
selectinload | One-to-many collections | Separate SELECT ... WHERE id IN (...) query. Default choice. |
joinedload | Many-to-one, one-to-one | Uses JOIN. Avoid for collections (creates cartesian product). |
subqueryload | Large one-to-many | Subquery instead of IN clause. Use when selectinload generates too many params. |
Use lazy="raise" to Catch Mistakes Early
class Deck(Base):
__tablename__ = "decks"
problems: Mapped[list["Problem"]] = relationship(lazy="raise")
Never Share a Session Across Concurrent Tasks
session = async_session_factory()
await asyncio.gather(
service_a.do_work(session),
service_b.do_work(session),
)
async def fetch_one(uid: UUID) -> User:
async with async_session_factory() as db:
return await db.get(User, uid)
results = await asyncio.gather(*[fetch_one(uid) for uid in ids])
Session-per-Request Transaction Boundary
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
class BaseRepository:
async def create(self, data, **extra) -> ModelT:
obj = self.model(**data.model_dump(), **extra)
self.db.add(obj)
await self.db.flush()
await self.db.refresh(obj)
return obj
Why flush, not commit, in services: If a request calls two services and the second one fails, the entire transaction rolls back -- including the first service's work. If the first service had called commit(), its work is already permanent and you have an inconsistent state.
Detached Instance Gotcha After Session Close
async def get_user(db: AsyncSession, user_id: UUID) -> User:
user = await db.get(User, user_id)
await db.commit()
return user
SQLAlchemy ORM Patterns
Modern Mapped Syntax (2.0+)
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
from sqlalchemy import String, DateTime, func
from datetime import datetime
import uuid
class Base(DeclarativeBase):
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
Postgres Enum Helper
Python enums and Postgres enums have a case mismatch. Python uses UPPERCASE by default, Postgres expects lowercase.
import enum
from sqlalchemy import Enum
class Difficulty(enum.StrEnum):
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
def pg_enum(enum_class: type[enum.StrEnum], name: str) -> Enum:
"""Create SQLAlchemy Enum with lowercase values for Postgres."""
return Enum(
enum_class,
name=name,
values_callable=lambda e: [x.value for x in e],
)
class Problem(TimestampMixin, Base):
__tablename__ = "problems"
difficulty: Mapped[Difficulty] = mapped_column(
pg_enum(Difficulty, "difficulty"), default=Difficulty.MEDIUM
)
Explicit Column Selection for Lists
TEXT columns live in PostgreSQL's TOAST tables and are expensive to retrieve in bulk.
result = await db.execute(select(Problem).where(Problem.user_id == user_id))
_LIST_COLUMNS = [
Problem.id, Problem.title, Problem.slug,
Problem.difficulty, Problem.language, Problem.tags,
Problem.created_at,
]
result = await db.execute(select(*_LIST_COLUMNS).where(Problem.user_id == user_id))
Bulk Updates with CASE WHEN
Single SQL statement for multi-row updates. More efficient than a loop.
from sqlalchemy import case, update
async def reorder_items(
db: AsyncSession, parent_id: UUID, ordered_ids: list[UUID]
) -> None:
whens = {item_id: idx for idx, item_id in enumerate(ordered_ids)}
stmt = (
update(DeckProblem)
.where(DeckProblem.deck_id == parent_id)
.values(position=case(whens, value=DeckProblem.problem_id))
)
await db.execute(stmt)
await db.flush()
Row-Level Locking for Concurrent Safety
async def add_item_to_deck(
db: AsyncSession, deck_id: UUID, problem_id: UUID
) -> DeckProblem:
await db.execute(
select(Deck).where(Deck.id == deck_id).with_for_update()
)
max_pos = await db.execute(
select(func.coalesce(func.max(DeckProblem.position), -1) + 1)
.where(DeckProblem.deck_id == deck_id)
)
next_position = max_pos.scalar_one()
item = DeckProblem(
deck_id=deck_id,
problem_id=problem_id,
position=next_position,
)
db.add(item)
await db.flush()
await db.refresh(item)
return item
JSONB for Semi-Structured Data
from sqlalchemy.dialects.postgresql import JSONB
class User(TimestampMixin, Base):
__tablename__ = "users"
ai_model_preferences: Mapped[dict[str, str] | None] = mapped_column(
JSONB, nullable=True, default=None
)
stmt = select(User).where(
User.ai_model_preferences["default_model"].as_string() == "claude-sonnet"
)
Pydantic v2 Patterns
Separate Create/Update/Read Schemas
class DeckCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
class DeckUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
class DeckRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
user_id: UUID
title: str
description: str
created_at: datetime
updated_at: datetime
class DeckListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
problem_count: int
created_at: datetime
Partial Updates with exclude_unset
async def update(self, id: UUID, data: UpdateSchemaT) -> ModelT:
obj = await self.get_by_id_or_raise(id)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(obj, field, value)
await self.db.flush()
await self.db.refresh(obj)
return obj
Why exclude_unset: Without it, DeckUpdate(title="New") would also set description=None, wiping the existing description. With exclude_unset=True, only title is included in the update dict.
field_validator Ordering Trap
Validators only see fields defined BEFORE them in the class.
class UserCreate(BaseModel):
password: str
password_confirm: str
@field_validator("password_confirm")
@classmethod
def passwords_match(cls, v: str, info: ValidationInfo) -> str:
if "password" in info.data and v != info.data["password"]:
raise ValueError("Passwords don't match")
return v
model_validator for Cross-Field Validation
class DateRange(BaseModel):
start_date: date
end_date: date
@model_validator(mode="after")
def end_after_start(self) -> "DateRange":
if self.end_date < self.start_date:
raise ValueError("end_date must be after start_date")
return self
model_validator(mode="before") for Input Normalization
class ProblemCreate(BaseModel):
tags: list[str] = []
@model_validator(mode="before")
@classmethod
def normalize_tags(cls, data: Any) -> Any:
if isinstance(data, dict) and isinstance(data.get("tags"), str):
data["tags"] = [t.strip() for t in data["tags"].split(",") if t.strip()]
return data
Reusable Validators with Annotated
from typing import Annotated
from pydantic import AfterValidator
def validate_slug(v: str) -> str:
if not v.replace("-", "").isalnum():
raise ValueError("Slug must be alphanumeric with hyphens")
return v.lower()
Slug = Annotated[str, AfterValidator(validate_slug)]
class DeckCreate(BaseModel):
slug: Slug
class ProblemCreate(BaseModel):
slug: Slug
computed_field for Derived Data
from pydantic import computed_field
class DeckResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
slot_count: int
filled_slot_count: int
@computed_field
@property
def completion_percentage(self) -> float:
if self.slot_count == 0:
return 0.0
return round(self.filled_slot_count / self.slot_count * 100, 1)
Default Values Are Not Validated
class Config(BaseModel):
retries: int = -1
@field_validator("retries")
@classmethod
def check_positive(cls, v: int) -> int:
if v < 0:
raise ValueError("Must be positive")
return v
class Config(BaseModel):
model_config = ConfigDict(validate_default=True)
retries: int = -1
Subclass Serialization Trap
class Animal(BaseModel):
name: str
class Dog(Animal):
breed: str
class Zoo(BaseModel):
animal: Animal
zoo = Zoo(animal=Dog(name="Rex", breed="Lab"))
zoo.model_dump()
from pydantic import SerializeAsAny
class Zoo(BaseModel):
animal: SerializeAsAny[Animal]
Service Layer Architecture
Decision Hierarchy
Route (thin controller)
→ validates input (Pydantic)
→ delegates to service
→ returns response
Service (business logic)
→ domain rules
→ orchestrates repositories
→ raises domain exceptions
Repository (data access)
→ SQL queries
→ flush/refresh
→ no business logic
Services Must Not Know About HTTP
from fastapi import HTTPException
class DeckService:
async def get_deck(self, deck_id: UUID) -> Deck:
deck = await self.repo.get_by_id(deck_id)
if not deck:
raise HTTPException(status_code=404)
return deck
from app.utils.exceptions import NotFoundError
class DeckService:
async def get_deck(self, deck_id: UUID) -> Deck:
deck = await self.repo.get_by_id(deck_id)
if not deck:
raise NotFoundError("Deck")
return deck
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": type(exc).__name__, "message": exc.message}},
)
Services Must Not Own the Session
class DeckService:
async def create_deck(self, data: DeckCreate) -> Deck:
async with async_session_factory() as db:
deck = Deck(**data.model_dump())
db.add(deck)
await db.commit()
return deck
class DeckRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, data: DeckCreate, **extra) -> Deck:
deck = Deck(**data.model_dump(), **extra)
self.db.add(deck)
await self.db.flush()
await self.db.refresh(deck)
return deck
@router.post("/clone")
async def clone_problem(body: CloneRequest, db: DbSession, user: CurrentUser):
problem_repo = ProblemRepository(db)
viz_repo = VisualizationRepository(db)
problem = await problem_repo.create(...)
await viz_repo.clone_for_problem(original_id, problem.id)
return problem
When NOT to Use a Service
Don't wrap a single ActiveRecord/ORM call in a service just for architecture's sake.
class UserService:
async def get_user(self, user_id: UUID) -> User:
return await self.repo.get_by_id(user_id)
@router.get("/users/{user_id}")
async def get_user(user_id: UUID, db: DbSession) -> User:
user = await db.get(User, user_id)
if not user:
raise NotFoundError("User")
return user
Use a service when:
- There's business logic beyond CRUD
- Multiple repositories need to coordinate
- The operation has side effects (emails, webhooks, analytics)
- The logic needs to be tested independently of HTTP
Cross-Service Operations
class CommunityService:
def __init__(self, db: AsyncSession):
self.db = db
self.problem_repo = ProblemRepository(db)
self.viz_repo = VisualizationRepository(db)
async def clone_problem(self, problem_id: UUID, user_id: UUID) -> Problem:
original = await self.problem_repo.get_by_id_or_raise(problem_id)
cloned = await self.problem_repo.create(...)
await self.viz_repo.clone_for_problem(original.id, cloned.id)
return cloned
Error Handling
Exception Hierarchy
class AppError(Exception):
"""Base exception for all domain errors."""
def __init__(self, message: str = "An error occurred", status_code: int = 500):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class NotFoundError(AppError):
def __init__(self, resource: str = "Resource"):
super().__init__(f"{resource} not found", status_code=404)
class AuthenticationError(AppError):
def __init__(self, message: str = "Authentication failed"):
super().__init__(message, status_code=401)
class AuthorizationError(AppError):
def __init__(self, message: str = "Insufficient permissions"):
super().__init__(message, status_code=403)
class ValidationError(AppError):
def __init__(self, message: str = "Validation failed"):
super().__init__(message, status_code=400)
class ConflictError(AppError):
def __init__(self, message: str = "Conflict"):
super().__init__(message, status_code=409)
class ExternalServiceError(AppError):
def __init__(self, service: str, message: str = ""):
super().__init__(f"{service} error: {message}", status_code=502)
Global Exception Handlers
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": type(exc).__name__, "message": exc.message}},
)
@app.exception_handler(RequestValidationError)
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
safe_errors = []
for err in exc.errors():
safe_err = {**err}
if "ctx" in safe_err:
safe_err["ctx"] = {k: str(v) for k, v in safe_err["ctx"].items()}
safe_errors.append(safe_err)
return JSONResponse(
status_code=422,
content={"error": {
"code": "ValidationError",
"message": "Request validation failed",
"detail": safe_errors,
}},
)
@app.exception_handler(Exception)
async def handle_unhandled(request: Request, exc: Exception) -> JSONResponse:
logger.error("unhandled_exception", exc_info=exc)
return JSONResponse(
status_code=500,
content={"error": {"code": "InternalServerError", "message": "Internal server error"}},
)
HTTPException in Middleware Does NOT Hit Exception Handlers
@app.middleware("http")
async def my_middleware(request: Request, call_next):
raise HTTPException(status_code=401)
@app.middleware("http")
async def my_middleware(request: Request, call_next):
return JSONResponse(
status_code=401,
content={"error": {"code": "Unauthorized", "message": "..."}},
)
Consistent Error Envelope
Every error response follows the same shape:
{
"error": {
"code": "NotFoundError",
"message": "Deck not found",
"detail": null
}
}
This includes Pydantic validation errors, domain errors, and unhandled exceptions. The frontend always checks response.error.message -- never different shapes for different error types.
Authentication & Authorization
Dependencies Over Middleware for Auth
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
payload = decode_token(credentials.credentials)
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("Invalid token")
result = await db.execute(select(User).where(User.id == UUID(user_id)))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise AuthenticationError("User not found or inactive")
return user
Why dependencies, not middleware:
- Type-safe: returns
User, not request.state.user
- Only runs on routes that need it (no whitelist)
- Composes:
require_role chains on get_current_user
- FastAPI caches per request (multiple deps calling
get_current_user resolve it once)
Parameterized Role Dependencies
def require_role(*roles: UserRole) -> Callable[..., Awaitable[User]]:
async def checker(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise AuthorizationError("Insufficient permissions")
return user
return checker
@router.delete("/users/{user_id}")
async def delete_user(user: User = Depends(require_role(UserRole.ADMIN))):
...
JWT Token Pair Pattern
def create_access_token(user_id: UUID) -> str:
expire = datetime.now(UTC) + timedelta(minutes=30)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def create_refresh_token(user_id: UUID) -> str:
expire = datetime.now(UTC) + timedelta(days=7)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise AuthenticationError("Token expired")
except jwt.PyJWTError:
raise AuthenticationError("Invalid token")
Important: Token type validation. Always check payload["type"] matches the expected type. Without this, a refresh token can be used as an access token.
Pagination
Offset-Limit with Dependency
from fastapi import Query
class PaginationParams:
def __init__(
self,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=250),
):
self.page = page
self.limit = limit
self.offset = (page - 1) * limit
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
limit: int
has_more: bool
@classmethod
def create(cls, *, items: list, total: int, page: int, limit: int):
return cls(
items=items, total=total, page=page, limit=limit,
has_more=(page * limit) < total,
)
When to Consider Cursor-Based Pagination
Offset pagination is O(n) for deep pages (OFFSET 10000 scans and discards 10,000 rows).
Consider cursor-based pagination when:
- Users can scroll through 1,000+ items
- You have an activity feed or timeline
- Response time degrades noticeably on page 50+
For most apps (decks, problem lists, admin tables), offset pagination is fine.
Background Jobs
Decision Matrix
| Criteria | BackgroundTasks | arq / SAQ | Celery |
|---|
| Survives server crash | No | Yes (Redis) | Yes |
| Retry with backoff | No | Yes | Yes |
| Status tracking | No | Yes | Yes |
| Separate process | No | Yes | Yes |
| Setup complexity | None | Low | High |
| Best for | Fire-and-forget (emails, logging) | Async I/O (LLM calls, webhooks) | CPU-heavy, distributed |
When to Use BackgroundTasks
@router.post("/users", status_code=201)
async def create_user(data: UserCreate, background_tasks: BackgroundTasks):
user = await service.create(data)
background_tasks.add_task(send_welcome_email, user.email)
return user
When to Use a Job Queue
@router.post("/visualizations/generate")
async def generate_viz(data: GenerateRequest, user: CurrentUser):
job = await redis_pool.enqueue_job(
"generate_visualization",
data.model_dump(),
_job_id=f"viz-{user.id}-{uuid4()}",
)
return {"job_id": job.job_id, "status": "queued"}
Idempotent Job Design
async def process_payment(ctx, order_id: str):
order = await get_order(order_id)
await charge_card(order.amount)
order.status = "paid"
await save(order)
async def process_payment(ctx, order_id: str):
order = await get_order(order_id)
if order.status == "paid":
return
await charge_card(order.amount, idempotency_key=order_id)
order.status = "paid"
await save(order)
Testing
Test Database Setup
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import NullPool
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/myapp_test"
test_engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture(autouse=True)
async def setup_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
Dependency Override for Tests
@pytest.fixture
async def client():
async def override_get_db():
async with test_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
app.dependency_overrides.clear()
Test Fixture Pattern
@pytest.fixture
async def test_user(db: AsyncSession) -> User:
user = User(
id=uuid4(),
email="test@example.com",
github_id="12345",
github_username="testuser",
role=UserRole.USER,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@pytest.fixture
async def auth_headers(test_user: User) -> dict:
token = create_access_token(test_user.id)
return {"Authorization": f"Bearer {token}"}
Unit Tests: Mock the Session, Not the Service
@pytest.mark.asyncio
async def test_deck_service_rejects_duplicate_problem():
mock_db = AsyncMock(spec=AsyncSession)
mock_db.execute.return_value = MagicMock(
scalar_one_or_none=MagicMock(return_value=existing_deck_problem)
)
with pytest.raises(ConflictError, match="already in deck"):
await add_problem_to_deck(mock_db, deck_id, problem_id)
Integration Tests: Hit the Real Stack
@pytest.mark.asyncio
async def test_create_deck(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/api/decks",
json={"title": "Arrays", "description": "Array problems"},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Arrays"
assert "id" in data
Test Error Paths, Not Just Happy Paths
@pytest.mark.asyncio
async def test_get_deck_not_found(client: AsyncClient, auth_headers: dict):
response = await client.get(f"/api/decks/{uuid4()}", headers=auth_headers)
assert response.status_code == 404
assert response.json()["error"]["code"] == "NotFoundError"
@pytest.mark.asyncio
async def test_create_deck_unauthorized(client: AsyncClient):
response = await client.post("/api/decks", json={"title": "Test"})
assert response.status_code == 403
Database Migrations
Review Autogenerated Migrations
Alembic autogenerate produces false positives:
- Dropping and recreating indexes that haven't changed
- Reordering columns
- Recreating enum types
Always read every migration before running it. Delete the noise.
Enum Types: Always Raw SQL
def upgrade():
sa.Enum("easy", "medium", "hard", name="difficulty").create(op.get_bind())
def upgrade():
op.execute("""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'difficulty')
THEN CREATE TYPE difficulty AS ENUM ('easy', 'medium', 'hard');
END IF;
END $$
""")
Adding Values to an Existing Enum
def upgrade():
op.execute("""
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'ruby'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'language')
) THEN
ALTER TYPE language ADD VALUE 'ruby';
END IF;
END $$
""")
Never Hardcode Revision IDs
Let Alembic generate unique revision IDs. Copying from examples or other migrations causes conflicts.
alembic revision --autogenerate -m "add language column"
Test Migrations Both Ways
- Against a clean database (from scratch)
- Against current production state (incremental)
A migration that works from scratch but fails on production data (or vice versa) will bite you on deploy.
Configuration
Pydantic BaseSettings with Production Validation
from pydantic_settings import BaseSettings, SettingsConfigDict
_WEAK_SECRET = "dev-secret-change-me"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env",),
case_sensitive=False,
)
debug: bool = False
cors_origins: str = "http://localhost:3000"
database_url: str = "postgresql+asyncpg://localhost/myapp"
secret_key: str = _WEAK_SECRET
access_token_expire_minutes: int = 30
db_pool_size: int = 5
db_pool_recycle: int = 1800
@model_validator(mode="after")
def enforce_production(self) -> "Settings":
if not self.debug and self.secret_key == _WEAK_SECRET:
raise ValueError("SECRET_KEY must be set in production")
if self.database_url.startswith("postgresql://"):
self.database_url = self.database_url.replace(
"postgresql://", "postgresql+asyncpg://", 1
)
return self
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",")]
settings = Settings()
Engine Configuration for PaaS
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=5,
max_overflow=5,
pool_timeout=30,
pool_recycle=1800,
connect_args={
"server_settings": {"statement_timeout": "30000"}
},
)
Concurrency & Locking
with_for_update() for Serialized Access
See Row-Level Locking above.
Use when:
- Multiple requests can modify the same parent (e.g., adding items to a deck)
- Position/ordering needs to be sequential
- You need to read-then-write atomically
Don't use when:
- Reads only (no locking needed)
- Low contention (personal projects with single-user access)
Atomic Counter Updates
problem = await db.get(Problem, problem_id)
problem.clone_count += 1
from sqlalchemy import update
stmt = (
update(Problem)
.where(Problem.id == problem_id)
.values(clone_count=Problem.clone_count + 1)
)
await db.execute(stmt)
Quick Reference
| Mistake | Fix |
|---|
expire_on_commit=True (default) | Set expire_on_commit=False on async sessionmaker |
| Lazy loading relationships in async | Use selectinload/joinedload explicitly |
commit() inside service methods | Use flush() in services, commit() at request boundary |
HTTPException in services | Raise domain exceptions (NotFoundError, etc.) |
SELECT * on list endpoints | Select specific columns, exclude TEXT fields |
ORDER BY random() for queues | Pre-compute priority, or use offset-based sampling |
| Hardcoded enum creation in migrations | Raw SQL with IF NOT EXISTS |
sa.Enum.create() in Alembic | Raw SQL always |
any in Pydantic field types | Use unknown patterns or specific union types |
| Default values not validated | Set validate_default=True in model_config |
Session shared across asyncio.gather | One session per concurrent task |
HTTPException in middleware | Return JSONResponse directly |
| Mutable default arguments | Use Field(default_factory=list) not Field(default=[]) |
| Route handles business logic | Extract to service layer |
| Missing error path tests | Test 404, 401, 403, 422 alongside happy paths |