Dependency Injection

Idiomatic dependency injection in FastAPI: settings, singletons, and per-request objects.
2026-03-18

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 Annotated type aliases throughout (available since FastAPI 0.95+). Older tutorials show param: Settings = Depends(get_settings) — that still works, but the Annotated form 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 with yield for 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 app

Schema 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 — but app.state integrates better with the app factory pattern and dependency_overrides in tests: you can create multiple app instances with different engines without monkey-patching module-level globals.

Warning

Starlette’s app.state is dynamically typed — request.app.state.engine will cause Mypy / Pyright errors because the attribute doesn’t exist in the State class definition. Suppress with # type: ignore[attr-defined], or use cast to 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_serviceget_booking_repoget_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_session

Key properties:

  • Each request gets its own session, repo, and service — no shared mutable state between requests.
  • Depends results are cached within a request: if multiple dependencies need the same SessionDep, 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.state as 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_repo as a Depends becomes 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_repoget_service → endpoint) stays the same — swap Session for AsyncSession in the repo’s type hints.

Note

With async, you store a session_factory (an async_sessionmaker) on app.state rather than the raw engine, because AsyncSession(engine) is not the intended API — async_sessionmaker configures session options (like expire_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 injectingPatternWhy
Read-only config (Settings)@lru_cache + DependsDocs-blessed; memoizes .env and environment read; no teardown needed
SQL engine / connection poolLifespan + app.stateProcess-scoped; teardown via engine.dispose() after yield
SQL session, repo, serviceDepends chain with sub-dependenciesORM 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 + DependsClient 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-patternWhy it’s badFix
Module-level engine = create_engine(...)Couples to import-time settings; can’t vary per test without monkey-patchingCreate engine in lifespan from settings, store on app.state
Module-level service = None # type: ignore set at startupNot type-safe, not testable without monkey-patching, invisible lifecycleLifespan + app.state + Depends
@lru_cache on stateful dependencies (repos, services)No teardown, outside FastAPI’s lifecycle, awkward with dependency_overridesLifespan + app.state for singletons, Depends chain for per-request
Singleton repos that open/close sessions internallyHides session scope; harder to test; risk of leaking sessions across requestsPer-request DI chain — inject the session into the repo
Importing singletons directly (from src.repo import repo)Untestable, hidden coupling, no lifecycle managementAlways inject via Depends

Thread Safety

FastAPI runs sync route handlers in a threadpool. Shared objects may be accessed from multiple threads concurrently.

  • Settings is 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 at await points.
    • Or delegate to a database / cache that handles concurrency internally.
  • Per-worker-process: with uvicorn --workers N (or Gunicorn), each worker is a separate OS process with its own copy of app.state. Singletons are not shared across workers — keep this in mind for in-memory caches, warmup data, and startup side effects.

Further Reading