How FastAPI dependency injection works: a per-request call graph

FastAPI dependency injection is a way of declaring what a path operation needs instead of constructing it inside the handler. You mark a parameter with Depends() and pass it a callable: a function, a class, or anything else FastAPI can inspect. That callable can declare its own parameters, including further Depends() markers, so the declarations form a graph. On every request, FastAPI walks that graph, resolves the leaves first, and passes the results upward until the handler has its arguments. The same declarations also feed request validation and the OpenAPI schema, which is a large part of why teams adopt the pattern.

The per-request cache

Within a single request, FastAPI caches each dependency's result, keyed on the dependency callable. If get_current_user and get_audit_logger both depend on get_db_session, the session is created once and both receive the same object. This is usually what you want, because both should work inside one session and one transaction. It is the wrong behavior when each consumer expects fresh state, such as a mutable accumulator or a new random token. Passing use_cache=False to Depends() makes FastAPI call the dependency again at that point in the graph.

No container lifetimes

Container-based frameworks typically provide singleton, scoped, and transient lifetimes. FastAPI's dependency system has only one built-in scope: the request. Anything that should live longer has to be managed some other way, and the rest of this guide covers how to do that.

These details matter most to backend and platform engineers building HTTP services on FastAPI, where connection handling and test isolation are shaped by this resolution model.

from uuid import uuid4

from fastapi import Depends, FastAPI

app = FastAPI()


class FakeSession:
    # Stand-in for a real DB session so the example runs without a database
    def __init__(self) -> None:
        self.id = uuid4().hex[:8]


def get_db_session() -> FakeSession:
    # Resolved once per request, then reused by every dependent in that request
    return FakeSession()


def get_current_user(db: FakeSession = Depends(get_db_session)) -> dict:
    return {"user": "alice", "session": db.id}


def get_audit_logger(db: FakeSession = Depends(get_db_session)) -> dict:
    return {"logger": "audit", "session": db.id}


def new_token() -> str:
    return uuid4().hex


@app.get("/demo")
def demo(
    user: dict = Depends(get_current_user),
    audit: dict = Depends(get_audit_logger),
    token_a: str = Depends(new_token),
    token_b: str = Depends(new_token),  # cache hit: same value as token_a
    token_c: str = Depends(new_token, use_cache=False),  # called again
) -> dict:
    return {
        "same_session": user["session"] == audit["session"],  # True
        "a_equals_b": token_a == token_b,  # True
        "a_equals_c": token_a == token_c,  # False
    }

Two sub-dependencies share one cached session within a request, and use_cache=False forces a fresh call where new state is needed.

App-lifetime resources without a singleton scope

FastAPI's dependency system has no singleton or application scope. Every dependency is resolved per request, and its result is cached only within that request. Anything that should live as long as the process, such as a database connection pool, an async HTTP client with keep-alive connections, or parsed configuration, has to be created outside the DI graph.

The usual place is the lifespan handler. It is an async context manager passed to the FastAPI constructor: code before its yield runs at startup, and code after it runs at shutdown. Inside it you open the pool and the client, attach them to app.state or assign them to module-level variables, and close them after the yield. A thin dependency then reads request.app.state.pool and returns it. Route functions declare that dependency and never touch app.state directly. This keeps the access path in one place, so an override can still replace it.

The cost is that the lifetime is now invisible to the DI graph. FastAPI does not know the pool exists, so it cannot order its teardown relative to other resources or report a missing one. Tests can override the thin accessor, but code that reads app.state or a global directly bypasses overrides. That means you also have to patch those reads, or run the real lifespan.

Settings follow the same pattern. Wrap config loading in a function decorated with functools.lru_cache, so the environment is parsed once, and inject that function as a dependency. A test then sets app.dependency_overrides[get_settings] to return a fixture-built settings object. It does not need to mutate os.environ, which leaks between tests and between threads.

import os
from contextlib import asynccontextmanager
from dataclasses import dataclass
from functools import lru_cache

import httpx
from fastapi import Depends, FastAPI, Request

@dataclass(frozen=True)
class Settings:
    upstream_url: str
    timeout_s: float

@lru_cache
def get_settings() -> Settings:
    # Parsed once per process. Tests set app.dependency_overrides[get_settings]
    # instead of mutating os.environ.
    return Settings(
        upstream_url=os.environ.get("UPSTREAM_URL", ""),
        timeout_s=float(os.environ.get("UPSTREAM_TIMEOUT_S", "5")),
    )

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Direct call: dependency overrides do NOT apply here, which is the hidden-lifetime cost.
    settings = get_settings()
    app.state.http = httpx.AsyncClient(base_url=settings.upstream_url, timeout=settings.timeout_s)
    yield  # the app serves requests while suspended here
    await app.state.http.aclose()

app = FastAPI(lifespan=lifespan)

def get_http(request: Request) -> httpx.AsyncClient:
    # The single read of app.state; routes depend on this so tests can override it.
    return request.app.state.http

@app.get("/status")
async def status(http: httpx.AsyncClient = Depends(get_http), settings: Settings = Depends(get_settings)):
    resp = await http.get("/health")
    return {"upstream": settings.upstream_url, "code": resp.status_code}

A lifespan handler creates a shared httpx.AsyncClient on app.state. A thin get_http dependency exposes it to routes, and an lru_cache-wrapped get_settings function provides config that tests can override.

Yield dependencies for per-request resources and cleanup

A dependency written as a generator splits into two phases. Code before yield runs while FastAPI resolves the request's dependency graph. The yielded value is what the endpoint receives. Code after yield runs once the endpoint has finished. This fits resources whose lifetime should match a single request, and a database session is the standard case. You open the session before the yield and close it in a finally block, so it is released whether the request succeeds or not.

Commit and rollback rely on how FastAPI drives the generator. If the endpoint raises, FastAPI throws that exception back into the generator at the yield line. Wrapping the yield in try/except/finally lets you commit on success, roll back in except, and close in finally. After rolling back, re-raise the exception. If you swallow it, the error never reaches FastAPI's exception handlers, and the client may get a misleading response.

Teardown is a weak place for heavy or fragile work. If closing a connection or flushing a buffer raises during cleanup, that new exception can replace the original one, and your logs will show the cleanup failure instead of the bug that caused it. Keep teardown short and idempotent, and log the original error before running anything that might fail.

The order of teardown relative to sending the response and running background tasks has changed between FastAPI releases. Depending on the version, cleanup may run before the client gets the response or after it. Check the documentation for the version you deploy before relying on a session still being open inside a background task.

from typing import Iterator

from fastapi import Depends, FastAPI
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker

engine = create_engine("postgresql+psycopg://app:changeme@db.internal:5432/appdb")
SessionLocal = sessionmaker(bind=engine)

app = FastAPI()


def get_db() -> Iterator[Session]:
    # Setup: runs while FastAPI resolves this request's dependencies.
    db = SessionLocal()
    try:
        yield db  # the endpoint receives this session
        # Reached only if the endpoint returned without raising.
        db.commit()
    except Exception:
        # An exception raised in the endpoint is thrown back in at the yield.
        db.rollback()
        raise  # re-raise so FastAPI's exception handlers still see it
    finally:
        # Keep teardown short: an error here can replace the original one.
        db.close()


@app.post("/orders/{order_id}/cancel")
def cancel_order(order_id: int, db: Session = Depends(get_db)) -> dict:
    result = db.execute(
        text("UPDATE orders SET status = 'cancelled' WHERE id = :id"),
        {"id": order_id},
    )
    if result.rowcount == 0:
        # Raising here triggers the rollback branch in get_db.
        raise ValueError(f"order {order_id} not found")
    return {"id": order_id, "status": "cancelled"}

A yield dependency gives each request its own SQLAlchemy session. It commits when the endpoint succeeds, rolls back and re-raises when the endpoint throws, and always closes the session in finally.

Class-based, parameterized and router-level dependencies

When a check needs configuration, such as which permission a route requires, a plain function is not enough. You have two options. The first is a class whose __init__ stores the setting and whose __call__ performs the check. The second is a factory function that takes the setting and returns an inner function. In both cases you pass the instance or the returned closure to Depends. FastAPI inspects the signature of __call__ or the closure to resolve that dependency's own sub-dependencies.

The per-request cache identifies a dependency by the callable object itself, not by its arguments. Two separate instances, or two closures from two factory calls, are cached separately even if their configuration is identical, so both run in the same request. To share one result across routes and sub-dependencies, create each configured checker once at module level and reuse that object.

For cross-cutting concerns whose return value handlers never read, such as authentication gates, rate limits or audit logging, use dependencies=[...]. You can attach it to an APIRouter, pass it to include_router, or set it on the FastAPI app. These run for every matching route, and their return values are discarded.

Aspect def dependency async def dependency
Runs on Threadpool worker Event loop
Blocking call inside Ties up one worker Stalls every in-flight request
Best for Blocking drivers, file I/O, CPU-light sync libraries Awaited I/O with async clients
Overhead Thread handoff per call None beyond the coroutine

Choose async def only when every I/O call inside it is awaited. If a library blocks, use def. The threadpool is finite, though, so many slow sync dependencies can still exhaust it under load.

from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException

def get_user(x_user_role: str = Header(default="viewer")) -> dict:
    # Plain def: FastAPI runs it in the threadpool, so a blocking DB lookup is safe here
    return {"role": x_user_role}

class RequireRole:
    def __init__(self, role: str):
        self.role = role  # configuration lives on the instance

    def __call__(self, user: dict = Depends(get_user)) -> dict:
        # FastAPI reads this signature to resolve sub-dependencies
        if user["role"] != self.role:
            raise HTTPException(status_code=403, detail="Forbidden")
        return user

def require_scope(scope: str):
    # Factory: every call returns a new closure, which is cached as a separate dependency
    async def checker(x_scopes: str = Header(default="")) -> None:
        if scope not in x_scopes.split():
            raise HTTPException(status_code=403, detail="Missing scope")
    return checker

# Build each configured checker once and reuse the object so the per-request cache dedupes it
require_admin = RequireRole("admin")
require_reports_read = require_scope("reports:read")

async def rate_limit() -> None:
    ...  # placeholder: only awaited calls to an async store belong in an async def

router = APIRouter(dependencies=[Depends(require_reports_read)])

@router.get("/reports")
async def list_reports(user: dict = Depends(require_admin)):
    # Same require_admin object as in include_router below, so it runs once per request
    return {"role": user["role"]}

app = FastAPI(dependencies=[Depends(rate_limit)])
app.include_router(router, prefix="/admin", dependencies=[Depends(require_admin)])

A class-based role checker and a scope-checking factory are each created once at module level and attached at the app, include_router, router and route levels, which shows return-value-free gates and per-request cache reuse of a single instance.

Testing with dependency_overrides and TestClient

Put the setup in a pytest fixture that builds the client, yields it, and calls app.dependency_overrides.clear() afterwards. dependency_overrides is a plain dict on the application object, so an override left behind by one test leaks into every later test that shares the app. Placing the cleanup after yield means it runs even when the test fails.

The dict key is the original callable object, not its name or its return type. You register app.dependency_overrides[get_session] = override_session, and FastAPI swaps in the override wherever get_session appears in the graph. That includes nested dependencies and router-level ones, so overriding the session once also covers the repository dependency that consumes it.

The override's parameters are resolved like any other dependency. It can declare its own Depends, but every parameter must be something FastAPI can supply from the request or the graph. Otherwise requests fail validation.

For the database, have the override open a connection, begin a transaction, yield a session bound to it, and roll back on teardown. Each test starts from the same state, and no truncation step is needed. If the code under test calls commit(), use your ORM's savepoint pattern so the commit does not end the outer transaction. For auth, override the current-user dependency with a function that returns a fixed fake user. This bypasses token parsing entirely.

Use with TestClient(app) as client: whenever the app reads state created in its lifespan. Without the context manager, startup does not run, and that state is missing.

Overrides fail silently when the route depends on a different object than the one you used as the key. Common causes are a wrapper, a closure recreated by a factory on each call, or a lambda. The real dependency then runs without any error. Keep factory results in module-level names and override those names.

When to avoid it and the alternatives

Depends only resolves inside FastAPI's request handling. A scheduled job, a queue consumer or a CLI script has no request, so nothing walks the graph for you. You either call the dependency functions by hand, including driving yield generators and running their cleanup yourself, or you rebuild the same wiring in a second place. Both paths drift. The manual call skips sub-dependencies someone adds later, and the duplicated wiring builds a session differently from the HTTP path.

Deep chains have a second cost. When an endpoint depends on a service, which depends on a repository, which depends on a session and a settings object, the actual construction order exists only at runtime. Finding out why a handler got a particular client means following Depends references through several modules. No single place shows what gets built and when.

If most of your logic runs outside HTTP, build objects explicitly. Keep endpoint dependencies as thin adapters that hand those objects to handlers.

Approach Lifetime control Testability Reuse outside HTTP
FastAPI Depends Per request, plus app-lifetime state via lifespan Strong via dependency_overrides Poor: manual calls or duplicated wiring
Plain constructor injection Explicit: the caller decides Strong: pass fakes to the constructor Full: no framework needed
Module-level factories Process-wide unless you add your own caching Moderate: needs patching or reset hooks Good, but global state leaks between tests
Dedicated DI container Declared scopes (singleton, transient, custom) Strong, through container overrides Full, but adds a dependency and its own concepts

Impact on development cost and engineering time

The main saving is debugging time. When every resource is either created per request or read explicitly from app state, tests cannot inherit a session, a cache entry or an open transaction from the test before them. Order-dependent failures, which are expensive because they rarely reproduce locally, become much less common.

The costs are real. app.dependency_overrides is a plain dictionary on the application object, so it is global mutable state. If threaded or concurrent async tests share one app instance, one test's override can silently apply to another test's request. Build a fresh app per test, or set overrides in a fixture that always clears them on teardown. Each dependency also runs on every request. Measure that overhead instead of assuming it is negligible:

Illustrative estimate (assumed numbers, not a benchmark): overhead per second = dependencies per request × cost per dependency × requests per second. With 6 dependencies × 0.05 ms × 2,000 req/s, that is 600 ms of work per second, roughly 0.6 of a CPU core.

Test type Speed Catches Misses
Overridden dependencies Fast Routing, validation, business logic Transactions, pool exhaustion, real SQL
Real database Slower Commit/rollback semantics, connection-pool behaviour, constraints Little, but costs more to run in CI

Checklist

  • Use overrides for most endpoint tests, and reset them in every fixture.
  • Run real-database tests on the code that touches transactions, locking or connection lifecycles.
  • Profile dependency chains on hot endpoints before you add more layers.
  • Avoid sharing one overridden app across concurrent tests.

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.