Most FastAPI applications need the same few dependencies in almost every route handler: a database session, application settings, and perhaps a logger or request ID. Building these correctly — with proper lifecycle management (session commits and rollbacks), caching (settings read once at startup), and clean separation (each dependency does one thing) — is the foundation of a maintainable FastAPI application. This lesson builds the exact dependencies used throughout the blog application.
The get_db Session Dependency
# app/dependencies.py
from typing import Generator, AsyncGenerator
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import SessionLocal, AsyncSessionLocal
import logging
logger = logging.getLogger(__name__)
# ── Synchronous session (for plain def handlers) ──────────────────────────────
def get_db() -> Generator[Session, None, None]:
"""
Yields a SQLAlchemy Session per request.
Commits on success, rolls back on any exception, always closes.
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
# ── Async session (for async def handlers) ────────────────────────────────────
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
"""
Yields an AsyncSession per request.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# ── Type alias for cleaner handler signatures ─────────────────────────────────
from fastapi import Depends
from typing import Annotated
DB = Annotated[Session, Depends(get_db)]
AsyncDB = Annotated[AsyncSession, Depends(get_async_db)]
# Usage in handler:
# def create_post(post_in: PostCreate, db: DB):
# ... (much cleaner than db: Session = Depends(get_db))
Annotated type alias pattern (DB = Annotated[Session, Depends(get_db)]) is a FastAPI 0.95+ convention that makes handler signatures shorter and more readable. Instead of writing db: Session = Depends(get_db) in every function, you write db: DB. FastAPI reads the Annotated metadata and extracts the Depends(get_db) automatically. This is now the recommended approach in the FastAPI documentation for commonly-used dependencies.get_db runs after the handler returns successfully. This means you do NOT need to call db.commit() in your route handlers — the dependency handles it. Only call db.flush() inside handlers when you need server-generated values (like auto-increment IDs) before the end of the handler. This pattern keeps all transaction boundary logic in one place.async def, use get_async_db; if it is def, use get_db. Mixing them causes performance issues: FastAPI runs sync dependencies in a thread pool when called from an async handler, which adds overhead and can block the event loop indirectly. Keep sync handlers with sync dependencies and async handlers with async dependencies consistently throughout the application.The get_settings Dependency
from functools import lru_cache
from fastapi import Depends
from app.config import Settings
@lru_cache
def get_settings() -> Settings:
"""
Returns the cached Settings singleton.
The lru_cache ensures settings are read and validated only once.
"""
return Settings()
# Annotated alias
SettingsDep = Annotated[Settings, Depends(get_settings)]
# Usage:
@app.get("/info")
def app_info(settings: SettingsDep):
return {
"app_name": settings.app_name,
"environment": settings.environment,
"version": "1.0.0",
}
# In tests: override to use test configuration
# app.dependency_overrides[get_settings] = lambda: Settings(
# environment="testing",
# database_url="postgresql://user:pass@localhost/test_db",
# )
Request-Scoped Logger and Request ID
import uuid
import logging
from fastapi import Request, Depends
def get_request_id(request: Request) -> str:
"""
Returns the X-Request-Id header value, or generates a new UUID.
"""
return request.headers.get("X-Request-Id") or str(uuid.uuid4())
def get_request_logger(
request: Request,
request_id: str = Depends(get_request_id),
) -> logging.Logger:
"""
Returns a logger with the request ID bound to every log record.
"""
logger = logging.getLogger("app.request")
# In a real app, use structlog or add a LogAdapter for context binding
return logging.LoggerAdapter(logger, {"request_id": request_id})
# Usage in handler
@app.post("/posts")
def create_post(
post_in: PostCreate,
db: DB,
log: logging.LoggerAdapter = Depends(get_request_logger),
):
log.info("Creating post", extra={"title": post_in.title})
... # all log entries from this handler carry request_id
Composing Dependencies
from fastapi import Depends
from typing import Annotated
# ── Compose existing dependencies into a higher-level dependency ───────────────
def get_post_or_404(
post_id: int,
db: Session = Depends(get_db),
) -> Post:
"""
Reusable dependency: loads a post by ID or raises 404.
Eliminates the repeated if-not-post-raise-404 pattern.
"""
post = db.get(Post, post_id)
if not post or post.deleted_at:
raise HTTPException(404, "Post not found")
return post
PostDep = Annotated[Post, Depends(get_post_or_404)]
# Now handlers that need a post look like this:
@app.get("/posts/{post_id}")
def get_post(post: PostDep):
return post # 404 already handled by the dependency
@app.patch("/posts/{post_id}")
def update_post(
post: PostDep, # loads and validates in one place
update: PostUpdate,
db: DB,
user: CurrentUser,
):
if post.author_id != user.id:
raise HTTPException(403, "Not your post")
...
Common Mistakes
Mistake 1 — Creating a new Settings() in every handler
❌ Wrong — re-reads .env file on every request:
@app.get("/")
def handler():
settings = Settings() # reads .env every request!
✅ Correct — use the lru_cache dependency:
@app.get("/")
def handler(settings: SettingsDep): # ✓ cached singleton
Mistake 2 — Calling db.commit() in handler when get_db already commits
❌ Wrong — double commit (harmless but confusing):
def create_post(post_in: PostCreate, db: DB):
post = Post(**post_in.model_dump())
db.add(post)
db.commit() # explicit commit — get_db will commit again on exit
✅ Correct — only flush (to get generated IDs), let get_db commit:
def create_post(post_in: PostCreate, db: DB):
post = Post(**post_in.model_dump())
db.add(post)
db.flush() # ✓ writes to DB, get_db commits at the end
Mistake 3 — Returning mutable state from a cached dependency
❌ Wrong — lru_cache returns the same mutable dict to all callers:
@lru_cache
def get_config() -> dict:
return {"counter": 0} # same dict shared across all requests!
# handler A: config["counter"] += 1 ← modifies shared state!
✅ Correct — return immutable or use Pydantic settings (immutable by default).
Quick Reference
| Dependency | Type | Lifecycle |
|---|---|---|
get_db |
yield (generator) | Open → handler → commit/rollback → close |
get_settings |
plain function + lru_cache | Once at startup, cached forever |
get_request_id |
plain function | Per-request, reads from headers |
get_post_or_404 |
plain function | Per-request, queries DB |
| Annotated alias | DB = Annotated[Session, Depends(get_db)] |
Convenience shorthand |