How FastAPI dependency injection resolves a request

When a request arrives, FastAPI inspects the signature of the path operation function and builds a graph of everything it needs before calling it. A parameter declared with Depends(...) tells FastAPI to call another callable first and pass in its return value. That callable can be a plain function, an async function, or a class. FastAPI treats a class like any other callable whose signature it can read.

The dependency's own parameters are resolved by the same rules as an endpoint's. A token: str parameter can come from a header or query string, and another Depends parameter pulls in a sub-dependency. The result is a tree resolved depth-first: the endpoint depends on get_current_user, which depends on get_session and on a token extractor. No container or registration step exists. The function signatures are the wiring.

Within a single request, FastAPI caches each dependency's result. If get_session appears in three places in the graph, it runs once and every consumer receives the same session object. Without this, an auth check and a handler could end up in separate transactions. To get a fresh call each time, pass use_cache=False to Depends. This is useful when the dependency produces a value that must not be shared.

This article treats DI as the single seam that owns resource lifetimes, such as opening and closing sessions or resolving identity. Production code stays explicit about what each endpoint needs, and tests swap behavior at that seam without patching internals. This matters most to backend and platform engineers running FastAPI services backed by a database and an authentication layer.

from typing import Annotated
from uuid import uuid4

from fastapi import Depends, FastAPI, Header, HTTPException

app = FastAPI()


class Session:
    # Stand-in for a real DB session; the id makes sharing visible
    def __init__(self) -> None:
        self.id = uuid4().hex[:8]


def get_session():
    # Code before yield runs at setup, the finally block runs at teardown
    session = Session()
    try:
        yield session
    finally:
        pass  # a real app would call session.close() here


def get_token(authorization: Annotated[str, Header()]) -> str:
    # Resolved by the same rules as an endpoint: read from the Authorization header
    return authorization.removeprefix("Bearer ")


def get_current_user(
    token: Annotated[str, Depends(get_token)],
    session: Annotated[Session, Depends(get_session)],  # sub-dependency
) -> dict:
    if token != "example-token":
        raise HTTPException(status_code=401, detail="Invalid token")
    return {"name": "alice", "session_id": session.id}


@app.get("/me")
def read_me(
    user: Annotated[dict, Depends(get_current_user)],
    session: Annotated[Session, Depends(get_session)],  # cached: same object the auth check got
    fresh: Annotated[Session, Depends(get_session, use_cache=False)],  # forced new call
):
    return {
        "shared_with_auth": user["session_id"] == session.id,  # True
        "fresh_is_separate": fresh.id != session.id,  # True
    }

A dependency tree in which the endpoint and get_current_user share one cached get_session result per request, while use_cache=False forces a separate session.

How it works: one request, step by step

The previous section described the resolution rules. This is the same mechanism traced through the lifecycle of one request, which is the model to keep in mind for the rest of the article.

  1. Signature analysis happens once. When a route is registered, FastAPI reads the endpoint's signature and the signatures of every Depends target beneath it. It records which parameters come from the path, query string, headers, cookies or body, and which are sub-dependencies. This work is not repeated per request.
  2. Inputs are extracted and validated. For an incoming request, FastAPI pulls the declared values out of the request and validates them against their type annotations. A missing required header or a value that fails validation produces an error response before any of your code runs.
  3. The graph is resolved depth-first. Leaf dependencies run first. async def dependencies are awaited on the event loop, and plain def dependencies run in the threadpool. Each result is cached for the rest of the request unless that dependency was declared with use_cache=False.
  4. Generator dependencies pause at yield. For a dependency that uses yield, the code before yield runs during resolution, and the yielded value is what consumers receive. The function stays suspended at that point.
  5. The endpoint runs. It receives the resolved values as ordinary arguments. If any dependency raises HTTPException during resolution, the endpoint is never called and the exception becomes the response.
  6. Teardown runs in reverse order. After the endpoint finishes, FastAPI resumes each suspended generator dependency, so the code after yield and any finally blocks run, last-in, first-out. This is where sessions are closed.

Because every step is driven by the function signatures, the same lifecycle applies whether the dependency is a database session, an authenticated user or a configuration object. That uniformity is what lets one override mechanism replace any of them in tests.

Who should use it

This pattern fits teams that meet most of the following conditions:

  • They run FastAPI services backed by a relational database. Per-request session ownership through a yield dependency is the main reason to adopt the pattern, so it pays off most when nearly every route touches the database.
  • Their services have authentication and authorization that varies by route. A layered chain of token validation, user loading and role guards, attached at router level, is easier to keep consistent across many endpoints than checks written inline in each handler.
  • They maintain an HTTP-level test suite. app.dependency_overrides gives a single, supported place to replace the session and the current user, which matters most when there are many tests that exercise routes through TestClient.
  • They have several engineers working in the codebase. Explicit signatures and shared Annotated aliases let someone new to a route see what it depends on without tracing imports.

It is a weaker fit in other cases:

  • Small services or prototypes with a handful of routes and no auth. A single session dependency is still worth having, but layered guards and aliases add structure the code may not need yet.
  • Systems where most logic runs outside the request cycle. If queue workers, scheduled jobs and CLIs do most of the work, FastAPI's resolver does not reach that code, as the final section explains. Those components need their own wiring, and a general-purpose approach shared across all entry points may matter more than request-scoped DI.
  • Teams that already use a DI container across every entry point. Running FastAPI's mechanism alongside a container gives two sources of truth for resource lifetimes. In that case, keep FastAPI dependencies as thin adapters that fetch objects from the container.

A yield-based SQLAlchemy session dependency

Every request gets its own session. A get_session dependency calls a sessionmaker factory, yields the session to the route, and closes it in a finally block, so the connection returns to the pool even when the handler raises. The session must not be a module-level global. A SQLAlchemy Session is not thread-safe. It also holds an identity map and transaction state, so a shared instance leaks objects and half-finished transactions between concurrent requests.

Commit and rollback belong in the route or service that owns the unit of work. Committing after yield looks tidy, but teardown code runs after the route has returned its result. A commit failure at that point can no longer produce a clean, accurate error response. The client may already believe the write succeeded. Keep teardown limited to cleanup such as close(), and treat it as ordered last-in, first-out across nested yield dependencies.

Match the dependency style to the engine:

Aspect Sync engine + Session Async engine + AsyncSession
Dependency signature def get_session() async def get_session()
Driver requirement Standard DBAPI driver Async driver (e.g. asyncpg)
Where it runs FastAPI's threadpool Event loop directly
Risk if mismatched Blocking I/O inside async def stalls the loop Sync driver calls block every request

A sync dependency under an async def route still works, because FastAPI offloads it to the threadpool. Each call costs a thread hop, though, and the pool has finite capacity. Under load, many sync dependencies queue behind each other while the event loop sits idle.

from typing import Annotated
from fastapi import Depends, FastAPI
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

engine = create_engine("postgresql+psycopg://app:change-me@db:5432/app")
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)

def get_session():
    session = SessionLocal()  # one session per request, never a module-level global
    try:
        yield session
    finally:
        session.close()  # cleanup only: returns the connection to the pool, no commit

async_engine = create_async_engine("postgresql+asyncpg://app:change-me@db:5432/app")
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)

async def get_async_session():
    session = AsyncSessionLocal()
    try:
        yield session
    finally:
        await session.close()

app = FastAPI()

@app.post("/orders")
async def create_order(session: Annotated[AsyncSession, Depends(get_async_session)]):
    try:
        await session.execute(text("INSERT INTO orders (status) VALUES ('new')"))
        await session.commit()  # commit before returning, so a failure becomes a real error response
    except Exception:
        await session.rollback()
        raise
    return {"status": "created"}

Sync and async yield-based session dependencies that only close the session during teardown. The route owns commit and rollback, so a failed write surfaces as an error before the response is sent.

Layering authentication and authorization as dependencies

Authentication fits this model well because each step depends on the output of the one before it. The chain usually has four links.

  1. Security scheme. A class such as OAuth2PasswordBearer or HTTPBearer reads the Authorization header and returns the raw token. If the header is missing it rejects the request, so nothing further down the chain runs. It also registers the scheme in the OpenAPI schema, which is how the docs UI knows to show an "Authorize" control.
  2. Token validation. A plain function takes the token, verifies its signature, expiry and audience, and returns decoded claims. It does no I/O, which keeps it cheap and easy to unit test.
  3. User loading. get_current_user depends on both the claims and the session dependency from the previous section. It fetches the user row, or raises 401 if the subject no longer exists.
  4. Narrow guards. require_active_user and role checks such as require_role("admin") depend on get_current_user and raise 403. Build each parameterized guard once at module level and reuse it.

Per-request caching makes this layering affordable. Suppose a route depends on both require_active_user and an admin guard. Both resolve get_current_user, and both trace back to the same session. FastAPI calls each dependency only once per request and reuses the result, so the user is loaded once and every link shares one transaction.

The cost is visibility. A route signature that declares only require_role("admin") hides the token decode, the user query and any role lookup underneath it. If roles are a lazy-loaded relationship, the guard issues another query, and a list endpoint that checks permissions per item can quietly become N+1. Load roles eagerly in get_current_user, and treat new auth dependencies as query changes during review.

from typing import Annotated

import jwt  # PyJWT
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload

from app.db import get_session  # session dependency from the previous section
from app.models import User

router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")  # 1. reads header, registers in OpenAPI
SECRET_KEY = "placeholder-load-from-env"

def decode_token(token: Annotated[str, Depends(oauth2_scheme)]) -> dict:
    # 2. Pure validation: signature, exp and aud. No I/O, so it's cheap to unit test.
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=["HS256"], audience="api.example.com")
    except jwt.InvalidTokenError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token", headers={"WWW-Authenticate": "Bearer"})

def get_current_user(claims: Annotated[dict, Depends(decode_token)],
                     session: Annotated[Session, Depends(get_session)]) -> User:
    # 3. One query, with roles loaded eagerly so the guards below never lazy-load
    stmt = select(User).options(selectinload(User.roles)).where(User.id == int(claims["sub"]))
    user = session.scalar(stmt)
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unknown subject")
    return user

def require_active_user(user: Annotated[User, Depends(get_current_user)]) -> User:
    if not user.is_active:
        raise HTTPException(status.HTTP_403_FORBIDDEN, "Inactive user")
    return user

def require_role(role: str):
    def guard(user: Annotated[User, Depends(require_active_user)]) -> User:  # 4. narrow guard
        if role not in {r.name for r in user.roles}:
            raise HTTPException(status.HTTP_403_FORBIDDEN, "Missing role")
        return user
    return guard

require_admin = require_role("admin")  # build once at module level, reuse everywhere

@router.get("/admin/reports")
def list_reports(admin: Annotated[User, Depends(require_admin)],
                 me: Annotated[User, Depends(require_active_user)]):
    # Both chains share one cached get_current_user call and one session per request
    return {"requested_by": me.id, "admin": admin.id}

A four-link FastAPI auth chain (OAuth2PasswordBearer, JWT decode, user load with eager roles, role guard) where per-request caching makes two guards on one route share a single user lookup and session.

Router-level dependencies and Annotated aliases

Once a guard is correct, the next problem is repetition. If every endpoint in an admin router repeats Depends(require_admin), someone will eventually add a route and forget it. Attach the check to the APIRouter instead, through its dependencies parameter, or pass dependencies to include_router. For checks that truly apply everywhere, pass dependencies to the FastAPI app. The guard then runs for every route in that scope, so a new endpoint is protected by default.

Dependencies listed this way have their return values discarded. They suit side-effect checks: verifying an API key header, rejecting a tenant that is suspended, enforcing a role. Each one either returns quietly or raises HTTPException. If the route needs the value, such as the user object, declare it in the signature as well. FastAPI caches a dependency's result within a single request by default, so a router-level get_current_user and an endpoint parameter that uses the same function share one call and one query.

Annotated aliases address the other kind of repetition. Define SessionDep = Annotated[Session, Depends(get_db)] and CurrentUser = Annotated[User, Depends(get_current_user)] once in a shared module. Route signatures then read db: SessionDep, user: CurrentUser. Type checkers see the real types, the wiring lives in one place, and changing how sessions or users are resolved does not mean editing every handler. Keep the aliases few and stable. An alias that hides several layers of nested dependencies makes per-request cost harder to see, which matters given the query budget above.

import os
import secrets
from typing import Annotated

from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, status
from sqlalchemy.orm import Session

from app.auth import get_current_user  # returns User or raises 401
from app.db import get_db  # yields a Session and closes it afterwards
from app.models import User

SessionDep = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]


def verify_api_key(x_api_key: Annotated[str, Header()]) -> None:
    # Side effect only: return nothing, or raise to reject the request.
    if not secrets.compare_digest(x_api_key, os.environ["SERVICE_API_KEY"]):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")


def require_admin(user: CurrentUser) -> None:
    if not user.is_admin:
        raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin only")


app = FastAPI(dependencies=[Depends(verify_api_key)])

admin_router = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])


@admin_router.get("/users/{user_id}")
def read_user(user_id: int, db: SessionDep, user: CurrentUser):
    # get_current_user is cached per request, so it runs once for this
    # route and require_admin.
    target = db.get(User, user_id)
    if target is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
    return {"id": target.id, "requested_by": user.id}


app.include_router(admin_router)

An app-level API key check and a router-level admin guard run only for their side effects, while SessionDep and CurrentUser keep route signatures short and share one cached user lookup per request.

Testing with dependency overrides and pytest fixtures

The point of routing sessions and identity through Depends is that tests can swap them without patching modules. app.dependency_overrides is a dictionary keyed by the original dependency callable. Map get_session to a function that yields a session bound to a test database, and map the current-user dependency to one that returns a fixed user. Every route that declares those dependencies, directly or through an Annotated alias, receives the replacement.

For isolation, open a connection, begin an outer transaction, bind the session to that connection, and roll back when the test ends. This is faster than recreating tables, and each test starts from the same state. Use one pytest fixture to install the overrides and yield the client. After the yield, clear the overrides in teardown, so that a failing test cannot leak its fake user into the next test.

This approach has costs:

  • Overrides are global mutable state on the app object. If parallel workers share one app instance, one test's override can apply to another test's request. Give each worker its own app, or keep override-dependent tests serial.
  • A fixed identity skips token validation entirely. Write separate tests that send real, expired and malformed tokens through the unmodified auth dependency.
  • Rollback isolation assumes all writes use that one connection. Code that calls commit() ends the outer transaction unless the session is set up to use savepoints. Code that opens its own engine or connection never joins the transaction at all. In those cases, wrap each test in savepoints or use a fresh database per test.

Keeping business logic outside the DI layer

Dependencies belong at the edge of the application. A service function such as create_order(session, customer_id, items) should take a Session as an ordinary argument and know nothing about Depends, Request or HTTP status codes. The route handler's only job is to resolve the session and the current user through DI, call the service, and translate its result or domain exceptions into a response.

This split pays off in three places. Unit tests can call the service directly with a session bound to a test database, with no TestClient and no overrides. A CLI script, a migration backfill or a queue worker can import the same function and pass in a session it created from the shared sessionmaker. And when the service raises OrderNotFound instead of HTTPException, a worker can retry or log the error in whatever way suits it.

Background work needs the same discipline about session ownership. The request-scoped session is tied to the dependency's lifecycle. Work scheduled with BackgroundTasks, or a generator feeding a StreamingResponse, can still be running after that dependency's cleanup has closed the session. When cleanup runs relative to response delivery has changed between FastAPI releases, so don't depend on either behaviour. Have the task or generator open its own session from the sessionmaker, commit or roll back inside it, and close it in a finally block. Pass primary keys into that code rather than ORM instances, because loading lazy attributes on an object from a closed session raises DetachedInstanceError.

When to avoid it, alternatives and engineering cost

FastAPI's resolver only runs when a request hits a path operation. Call a function that declares Depends(get_db) from a CLI command, a queue worker or a startup hook, and nothing is injected: the parameter's default is just the Depends marker object. Code outside the request lifecycle needs its own wiring. Trying to stretch request-scoped dependencies over that code usually produces duplicate session factories or fake request objects.

Approach Good fit Main cost
Monkeypatching Legacy code with no injection seams Patches target import paths, so they break when modules move
Module-level globals Immutable config, singletons Hidden coupling and import-time side effects
Context variables Per-task values such as request IDs and tenant Implicit flow that is hard to trace in review
Lifespan-managed state Engines, HTTP clients, caches Lifetime is the whole app, with no per-request scope
Dedicated DI container Large codebases with workers and CLIs An extra abstraction for the team to learn

On cost, explicit signatures help new engineers. A route's parameters show exactly what it touches, and tests swap one callable instead of stacking patches. The costs show up elsewhere. A dependency that runs a query is easy to miss in review because the query sits away from the route body. app.dependency_overrides is also a mutable dict on the app object, so an override that a test never clears will leak into later tests and cause failures that depend on test order.

Illustrative estimate (assumed numbers, not a benchmark):

Hours saved per month = tests written × minutes saved per test ÷ 60

Assume 40 tests a month and 5 minutes of mock setup saved on each test:

40 × 5 ÷ 60 ≈ 3.3 hours

Subtract the time you spend debugging leaked overrides. Clearing overrides in a fixture's teardown keeps that number close to zero.

How it saves engineering time

The time savings come from putting each cross-cutting concern in one place, so that changes and fixes happen once instead of per route or per test. The estimate above covers test setup only. The other sources of saved time are harder to put a number on, but they follow from the same structure.

  • Test setup. Replacing the session and the current user is two entries in app.dependency_overrides, installed by one shared fixture. Without a seam, each test that touches a route has to patch the right import paths, and those patches break when modules are moved or renamed.
  • Resource lifecycle bugs. Opening and closing sessions in a single yield dependency means there is one place to get connection cleanup right. Leaked connections and sessions shared across requests are the kind of fault that shows up only under load and takes a long time to trace. Centralizing the lifecycle removes most of the places where such a bug can be introduced.
  • Security review. A router-level guard protects every route in its scope, including routes added later. Reviewers check the guard once and confirm that a new route sits on the right router, instead of reading each handler for an auth check that might be missing.
  • Refactoring. Changing how users are resolved, for example moving from one token format to another, or adding eager loading of roles, means editing get_current_user and possibly its alias. Route handlers that declare CurrentUser do not change.
  • Onboarding and code reading. A route's signature lists what it needs. An engineer new to the codebase can see that an endpoint uses a session and requires an admin without tracing imports or global state.
  • Reuse outside HTTP. Keeping business logic in plain service functions that take a session argument lets workers, scripts and backfills call the same code, so the logic is written and tested once.

These savings depend on the discipline described in earlier sections. Hidden queries inside nested dependencies, overrides that are never cleared, and background tasks that reuse a closed request session all cost debugging time that can outweigh the gains. Count that time when you judge whether the pattern is paying off for your team, as the illustrative estimate above does.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to fastapi dependency injection, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.