Dependency Injection
Table of Contents
Patterns for wiring settings, repositories, services, DB pools, and per-request objects into FastAPI route handlers — using only what the framework gives you.
Note
This cookbook uses
Annotatedtype aliases throughout (available since FastAPI 0.95+). Older tutorials showparam: Settings = Depends(get_settings)— that still works, but theAnnotatedform is cleaner and reusable across multiple endpoints.
The Problem
You have objects (settings, repositories, services, DB pools) that route handlers need. You need to control how many instances exist, when they’re created and destroyed, and make them swappable in tests.
FastAPI’s native DI covers two lifetimes out of the box:
- Per-process singletons via the lifespan context manager
- Per-request objects via
Depends(optionally withyieldfor teardown)
That handles the vast majority of real-world apps. The examples below use SQLModel (sync) for the SQL layer and motor for MongoDB. Section 5 shows how the same patterns look with async SQLAlchemy.
Here is how all the pieces fit together — each numbered section below explains one part of this diagram:
graph TD
subgraph lifespanPhase ["Lifespan (process startup)"]
getSettings["get_settings()"]
createEngine["create_engine()"]
motorClient["AsyncIOMotorClient()"]
auditRepo["AuditRepository()"]
auditSvc["AuditService()"]
getSettings --> createEngine
getSettings --> motorClient
motorClient --> auditRepo
auditRepo --> auditSvc
end
createEngine -.->|"app.state.engine"| getSession
auditSvc -.->|"app.state.audit_service"| getAuditSvc
subgraph sqlChain ["Per-request DI chain (SQL)"]
getSession["get_session()"]
getRepo["get_booking_repo()"]
getBookingSvc["get_booking_service()"]
getSession -->|SessionDep| getRepo
getRepo -->|BookingRepoDep| getBookingSvc
end
subgraph mongoSingleton ["Singleton (MongoDB)"]
getAuditSvc["get_audit_service()"]
end
getBookingSvc -->|BookingServiceDep| handler["Route handler"]
getAuditSvc -->|AuditServiceDep| handler
1. Settings (read-only config from env / .env)
Pattern: lru_cache + Depends
Settings() reads from disk (.env) or the environment, so you memoize it. Since settings are immutable after creation, lru_cache is safe. (For the full Settings class setup — nested settings, prefixes, env-specific overrides — see the Configuration page.)
# src/dependencies.py
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from src.config import Settings
@lru_cache
def get_settings() -> Settings:
return Settings()
# Reusable type alias --- use this in any handler signature
SettingsDep = Annotated[Settings, Depends(get_settings)]# src/api.py
@router.get("/health")
def health(settings: SettingsDep) -> dict:
return {"status": "ok", "debug": settings.debug}Testing: override the dependency — lru_cache is bypassed entirely. Point it at a disposable Postgres container so your tests run against the same engine as production (never swap Postgres for SQLite — query behaviour, types, and constraints differ). testcontainers-python spins one up in a fixture. See Testing for the full fixture pattern.
app.dependency_overrides[get_settings] = lambda: Settings(
db=DatabaseSettings(url="postgresql://test:test@localhost:5433/test_db")
)If you call get_settings() directly outside the FastAPI request cycle (e.g. in a CLI command or a test helper), dependency_overrides has no effect. Reset the cache instead: get_settings.cache_clear().
2. Engine and connection pool (process-scoped)
Pattern: Lifespan + app.state
The database engine (and its underlying connection pool) is the one true process-scoped singleton in the SQL path. Create it in the lifespan context manager from settings and store it on app.state — no module-level globals. Everything else (sessions, repos, services) is per-request (next section).
# src/app.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlmodel import create_engine
from src.dependencies import get_settings
from src.routers import bookings
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
engine = create_engine(
str(settings.db.url), # ← nested settings (see Configuration)
pool_size=settings.db.pool_size,
echo=settings.db.echo,
)
app.state.engine = engine
yield
engine.dispose()
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
app.include_router(bookings.router)
return appSchema creation and migrations are handled by Alembic, not in the lifespan — the lifespan is only for wiring runtime resources.
Note
The official full-stack-fastapi-template uses a module-level global
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)). That works fine — the engine is a thread-safe connection pool — butapp.stateintegrates better with the app factory pattern anddependency_overridesin tests: you can create multiple app instances with different engines without monkey-patching module-level globals.
Warning
Starlette’s
app.stateis dynamically typed —request.app.state.enginewill cause Mypy / Pyright errors because the attribute doesn’t exist in theStateclass definition. Suppress with# type: ignore[attr-defined], or usecastto keep strict linters happy:from typing import cast from sqlalchemy import Engine engine = cast(Engine, request.app.state.engine)
3. Per-request DI chain (SQL layer)
Pattern: Depends sub-dependencies
When the ORM requires a session scoped to a unit of work (SQLAlchemy / SQLModel), the session, repository, and service should all be per-request. FastAPI’s sub-dependency system wires them into a chain — the “Per-request DI chain” box in the diagram above. Each link is a Depends function. FastAPI resolves them right-to-left, caches each result within the request, and tears down yield-based dependencies after the response is sent.
# src/dependencies.py
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, Request
from sqlmodel import Session
from src.repositories.bookings import BookingRepository
from src.services.bookings import BookingService
def get_session(request: Request) -> Iterator[Session]:
with Session(request.app.state.engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
def get_booking_repo(session: SessionDep) -> BookingRepository:
return BookingRepository(session)
BookingRepoDep = Annotated[BookingRepository, Depends(get_booking_repo)]
def get_booking_service(repo: BookingRepoDep) -> BookingService:
return BookingService(repo)
BookingServiceDep = Annotated[BookingService, Depends(get_booking_service)]The repo receives the session directly — it never opens or closes sessions itself:
# src/repositories/bookings.py
from collections.abc import Sequence
from sqlmodel import Session, select
from src.models.bookings import Booking
class BookingRepository:
def __init__(self, session: Session) -> None:
self.session = session
def list_all(self) -> Sequence[Booking]:
return self.session.exec(select(Booking)).all()
def get_by_id(self, booking_id: int) -> Booking | None:
return self.session.get(Booking, booking_id)The service receives the repo — it has no knowledge of sessions or the database:
# src/services/bookings.py
from src.repositories.bookings import BookingRepository
from src.schemas.bookings import BookingResponse
class BookingService:
def __init__(self, repo: BookingRepository) -> None:
self.repo = repo
def list_bookings(self) -> list[BookingResponse]:
return [BookingResponse.model_validate(b) for b in self.repo.list_all()]The endpoint declares only BookingServiceDep. FastAPI resolves the entire chain — get_booking_service → get_booking_repo → get_session — and tears down the session after the response:
# src/routers/bookings.py
from fastapi import APIRouter
from src.dependencies import BookingServiceDep
from src.schemas.bookings import BookingResponse
router = APIRouter(prefix="/bookings", tags=["bookings"])
@router.get("/")
def list_bookings(service: BookingServiceDep) -> list[BookingResponse]:
return service.list_bookings()Testing: override at whatever level you need. Each override short-circuits everything below it in the chain. See Testing for the full fixture pattern.
# swap the whole service (unit-test the endpoint in isolation)
app.dependency_overrides[get_booking_service] = lambda: BookingService(mock_repo)
# swap only the session (integration-test with a real repo against a test DB)
app.dependency_overrides[get_session] = lambda: test_sessionKey properties:
- Each request gets its own session, repo, and service — no shared mutable state between requests.
Dependsresults are cached within a request: if multiple dependencies need the sameSessionDep, the session is created only once.- Repos and services are lightweight objects — constructing them per-request costs virtually nothing compared to the I/O they perform.
Tip
Why per-request, not singletons? You could store the repo and service on
app.stateas singletons (passing the session factory to the repo and letting it manage sessions internally). That works, but:
- The repo now manages session lifecycle (open/close per method), which is harder to test and reason about.
get_repoas aDependsbecomes dead boilerplate — endpoints only use services, so nobody calls it.- It contradicts the FastAPI SQL tutorial and SQLModel docs, which both inject sessions per-request.
The singleton approach is correct for clients that manage their own connection pool and have no per-request session concept — see the next section.
4. Singleton services (non-SQL clients)
Pattern: Lifespan + app.state + Depends
Not every data store has a per-request session concept. MongoDB (via motor), Redis, HTTP API clients, or compute-heavy objects like ML models all manage their own connection pool or have no teardown-per-request need. For these, the entire client → repo → service chain is process-scoped: create everything in the lifespan, attach only the service to app.state.
# src/app.py (lifespan --- showing both SQL and MongoDB side by side)
from motor.motor_asyncio import AsyncIOMotorClient
from sqlmodel import create_engine
from src.repositories.audit import AuditRepository
from src.services.audit import AuditService
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
# SQL --- only the engine is process-scoped; sessions/repos/services are per-request
engine = create_engine(str(settings.db.url))
app.state.engine = engine
# MongoDB --- full singleton chain (motor manages its own pool)
mongo_client = AsyncIOMotorClient(str(settings.mongo.url))
mongo_db = mongo_client[settings.mongo.database]
audit_repo = AuditRepository(mongo_db)
app.state.audit_service = AuditService(audit_repo)
yield
engine.dispose()
mongo_client.close()Only the service goes on app.state. The client and repo are internal wiring details — endpoints never touch them, and exposing get_audit_repo as a Depends would be unnecessary boilerplate.
# src/dependencies.py
def get_audit_service(request: Request) -> AuditService:
return request.app.state.audit_service
AuditServiceDep = Annotated[AuditService, Depends(get_audit_service)]Testing: override the one dependency:
app.dependency_overrides[get_audit_service] = lambda: AuditService(mock_audit_repo)Mixed dependencies — a SQL-backed service can depend on both a per-request repo and a singleton:
def get_booking_service(
repo: BookingRepoDep, audit: AuditServiceDep
) -> BookingService:
return BookingService(repo, audit)FastAPI resolves both: repo is per-request (via the DI chain from section 3), audit is a singleton pointer-read from app.state.
The rule of thumb: if the client manages its own connection pool and has no request-scoped session, use singletons. If the ORM requires a session scoped to a unit of work (SQLAlchemy / SQLModel), use the per-request DI chain.
5. With async SQLAlchemy
The DI chain structure from section 3 is identical when using async SQLAlchemy — only the engine and session setup change. SQLModel table models work unchanged with AsyncSession.
# src/app.py (lifespan)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
engine = create_async_engine(str(settings.db.url)) # async driver, e.g. asyncpg
app.state.session_factory = async_sessionmaker(engine, expire_on_commit=False)
yield
await engine.dispose()# src/dependencies.py
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
async with request.app.state.session_factory() as session:
yield session
SessionDep = Annotated[AsyncSession, Depends(get_session)]The rest of the chain (get_repo → get_service → endpoint) stays the same — swap Session for AsyncSession in the repo’s type hints.
Note
With async, you store a
session_factory(anasync_sessionmaker) onapp.staterather than the raw engine, becauseAsyncSession(engine)is not the intended API —async_sessionmakerconfigures session options (likeexpire_on_commit) in one place. With sync SQLModel,Session(engine)is the standard API, so storing the engine directly is fine.
Decision Matrix
| What you’re injecting | Pattern | Why |
|---|---|---|
Read-only config (Settings) | @lru_cache + Depends | Docs-blessed; memoizes .env and environment read; no teardown needed |
| SQL engine / connection pool | Lifespan + app.state | Process-scoped; teardown via engine.dispose() after yield |
| SQL session, repo, service | Depends chain with sub-dependencies | ORM session must be scoped to a unit of work; per-request creation and teardown |
| Non-SQL client + repo + service (MongoDB, Redis, HTTP) | Lifespan + app.state + Depends | Client manages its own pool; no per-request session; only the service goes on app.state |
Anti-Patterns
Warning
These are common mistakes that look convenient at first but cause real pain in testing, lifecycle management, or type safety.
| Anti-pattern | Why it’s bad | Fix |
|---|---|---|
Module-level engine = create_engine(...) | Couples to import-time settings; can’t vary per test without monkey-patching | Create engine in lifespan from settings, store on app.state |
Module-level service = None # type: ignore set at startup | Not type-safe, not testable without monkey-patching, invisible lifecycle | Lifespan + app.state + Depends |
@lru_cache on stateful dependencies (repos, services) | No teardown, outside FastAPI’s lifecycle, awkward with dependency_overrides | Lifespan + app.state for singletons, Depends chain for per-request |
| Singleton repos that open/close sessions internally | Hides session scope; harder to test; risk of leaking sessions across requests | Per-request DI chain — inject the session into the repo |
Importing singletons directly (from src.repo import repo) | Untestable, hidden coupling, no lifecycle management | Always inject via Depends |
Thread Safety
FastAPI runs sync route handlers in a threadpool. Shared objects may be accessed from multiple threads concurrently.
Settingsis safe — it’s read-only after creation.- Engine / connection pool is safe — SQLAlchemy’s engine is designed for concurrent access.
- Per-request repos and services are safe — each request gets its own instances, so there is no shared mutable state.
- Singleton services (non-SQL, on
app.state) with mutable state need protection:- Sync handlers: use
threading.Lock. - Async handlers: use
asyncio.Lock— concurrent coroutines interleave atawaitpoints. - Or delegate to a database / cache that handles concurrency internally.
- Sync handlers: use
- Per-worker-process: with
uvicorn --workers N(or Gunicorn), each worker is a separate OS process with its own copy ofapp.state. Singletons are not shared across workers — keep this in mind for in-memory caches, warmup data, and startup side effects.
Further Reading
- FastAPI - Dependencies — how to use
Dependsto inject dependencies into route handlers. - FastAPI - Sub-dependencies — how to build dependency chains where one dependency depends on another.
- FastAPI - SQL Databases — official tutorial using SQLModel with
Dependsfor session injection. - SQLModel - Session with FastAPI Dependency — SQLModel’s recommended session-per-request pattern.
- FastAPI - Lifespan Events — how to use the lifespan context manager to create and manage resources.
- full-stack-fastapi-template — official project template showing SQLModel + FastAPI in production.
- Starlette - State — how to store state on the app instance.
- dependency-injector — third-party DI container; useful when you need DI outside route handlers (Celery tasks, CLI commands) or have deep dependency graphs. Check out how I use it here in my secret santa telegram bot.