Dependency Injection Strategies in FastAPI
Dependency injection is how a FastAPI handler states its requirements — a database session, an authenticated caller, a configured client — as parameters, and how the framework satisfies those requirements once per request with the whole graph visible to a type checker.
This is the load-bearing idea in Core Architecture and Routing Patterns. The application factory is where providers get bound to a running app, configuration reaches handlers through this same mechanism rather than through imports, and the request lifecycle places graph resolution after routing and before your code. This page is the map: what the graph is, where you can attach to it, what it caches, and where it stops. The individual manoeuvres each have their own guide, linked as they come up.
Prerequisites
You should be comfortable with Python type annotations and typing.Annotated, know what an asynchronous generator is, and have a FastAPI service with more than one router. Everything below was executed on FastAPI 0.139.2, Pydantic 2.13.4 and Python 3.12; where behaviour is version-specific it is called out.
Core mechanics: what FastAPI builds, and when
The graph is not assembled per request. At import time, when a decorator like @router.get(...) runs, FastAPI inspects the handler's signature and builds a Dependant — a tree describing every provider the route needs, each provider's own sub-providers, and where each parameter's value comes from. That tree is stored on the route object. Routing then costs a path match, and satisfying the tree costs a walk over a structure that already exists.
Three things fall out of this design, and they explain most of what people find surprising.
Resolution is depth-first over declaration order. Providers are visited in the order the signature lists them, and each provider's own dependencies are satisfied before the provider itself runs. So the order in which you write parameters is the order in which side effects happen.
Values are memoised per request, keyed by callable identity. When a provider has already been resolved during this request, the recorded value is reused rather than recomputed. The lookup compares the callable object itself, not its name or its source text, which is why two functions that look identical count as two entries. The full key — including how security scopes participate — is covered in dependency caching and use_cache.
Providers that yield are context managers. A provider written as a generator is entered on the way in and exited on the way out, and the exit half runs after the response has been produced. That makes it the correct home for anything that must be released.
Production implementation
Choosing an attachment site
There are exactly three places to attach a dependency, and the choice is not cosmetic — it decides when the provider runs and whether you can use its result. This example wires all three at once against a route that also takes two parameter-level providers, and records the order in which everything fired, reading the trace after the response completed so teardown is included.
async def load_settings() -> dict[str, str]:
# One shared node. Two different consumers below both ask for it.
TRACE.append("load_settings")
return {"region": "eu-west-1"}
async def app_wide_audit() -> None:
# Attached to FastAPI(dependencies=[...]): fires for every route in the whole service.
TRACE.append("app_wide_audit")
async def router_gate() -> None:
# Attached to APIRouter(dependencies=[...]): fires only for routes this router owns.
TRACE.append("router_gate")
async def current_user(settings: Annotated[dict, Depends(load_settings)]) -> dict[str, str]:
TRACE.append("current_user")
return {"id": "u-1", "region": settings["region"]}
async def db_session(
settings: Annotated[dict, Depends(load_settings)],
) -> AsyncIterator[str]:
TRACE.append("db_session:setup")
yield f"session@{settings['region']}"
TRACE.append("db_session:teardown")
svc = FastAPI(dependencies=[Depends(app_wide_audit)])
orders = APIRouter(prefix="/orders", dependencies=[Depends(router_gate)])
@orders.get("/{order_id}")
async def read_order(
order_id: int,
user: Annotated[dict, Depends(current_user)],
session: Annotated[str, Depends(db_session)],
) -> dict[str, Any]:
TRACE.append("handler")
# Only parameter-attached dependencies hand a value to the handler. The app- and
# router-level ones ran, but there is no name bound to their return value here.
return {"order_id": order_id, "user": user, "session": session}
The block below is the recorded output of actually running that service, not a description of it:
$ GET /on-a-router-route
200 OK
{
"status": 200,
"body": {
"order_id": 42,
"user": {
"id": "u-1",
"region": "eu-west-1"
},
"session": "session@eu-west-1"
},
"trace": [
"app_wide_audit",
"router_gate",
"load_settings",
"current_user",
"db_session:setup",
"handler",
"db_session:teardown"
]
}
$ GET /on-an-app-route
200 OK
{
"status": 200,
"body": {
"status": "ok"
},
"trace": [
"app_wide_audit"
]
}
Four facts are settled by those two traces. Attachment sites fire outermost-first, so the application gate precedes the router gate, which precedes anything in the signature. load_settings appears exactly once even though current_user and db_session both requested it. Teardown lands after the handler, outside the window a naive timer would measure. And a route registered on the app rather than on the router simply does not see the router's gate — which is the structural reason to keep unauthenticated endpoints on a separate router rather than maintaining an exemption list.
The same run demonstrates why an override is a graph operation rather than a handler operation. Substituting the shared load_settings node reaches both consumers at once:
$ GET /with-one-node-replaced
200 OK
{
"status": 200,
"body": {
"order_id": 42,
"user": {
"id": "u-1",
"region": "test-local"
},
"session": "session@test-local"
},
"trace": [
"app_wide_audit",
"router_gate",
"fake_settings",
"current_user",
"db_session:setup",
"handler",
"db_session:teardown"
]
}
Neither current_user nor db_session was named in that substitution, and both changed. The mechanics and the hazards of that — particularly overrides outliving the test that installed them — are worked through in overriding dependencies in tests.
What happens when a provider refuses
The second question a production graph raises is what survives a rejection. Here a session is declared before an authorisation gate, so the session is genuinely open at the moment the gate decides to reject:
async def db_session() -> AsyncIterator[str]:
TRACE.append("db_session:opened")
try:
yield "session-1"
finally:
# `finally` is what makes this safe: the gate below can raise while we are suspended here.
TRACE.append("db_session:closed")
async def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> str:
TRACE.append("require_api_key")
if x_api_key != "s3cret":
raise HTTPException(status_code=401, detail="Bad API key")
return x_api_key
async def audit_log(key: Annotated[str, Depends(require_api_key)]) -> None:
# Declared after the gate, so it is never reached when the gate rejects.
TRACE.append("audit_log")
@svc.get("/reports")
async def reports(
session: Annotated[str, Depends(db_session)],
key: Annotated[str, Depends(require_api_key)],
_audit: Annotated[None, Depends(audit_log)],
) -> dict[str, str]:
TRACE.append("handler")
return {"session": session, "report": "quarterly"}
Executed, both paths look like this:
$ GET /accepted
200 OK
{
"status": 200,
"body": {
"session": "session-1",
"report": "quarterly"
},
"trace": [
"db_session:opened",
"require_api_key",
"audit_log",
"handler",
"db_session:closed"
],
"handler_ran": true,
"session_was_released": true
}
$ GET /rejected
200 OK
{
"status": 401,
"body": {
"detail": "Bad API key"
},
"trace": [
"db_session:opened",
"require_api_key",
"db_session:closed"
],
"handler_ran": false,
"session_was_released": true
}
On rejection the handler never runs, audit_log never runs, and the session that was already open is still released. That combination is what makes it safe to declare expensive providers early: a later refusal cannot strand them. The accepted path also shows the cache at work, where require_api_key is requested by both the handler and audit_log yet appears once. The ordering rules for several yield providers unwinding together, and what changes when the endpoint itself raises, are covered in yield dependencies and cleanup order.
Where the graph stops
Knowing the boundary saves a category of wasted effort. A dependency is attached to a route, so it exists only where a route was matched. Three consequences follow directly.
A request that matches nothing resolves no dependencies at all, so an access log or a request counter built as a dependency will silently omit every 404 your service returns — and 404 rates are exactly what you want visibility on when a client ships a bad URL. A request handled by a mounted sub-application is routed by that sub-application, so the parent's router-level and application-level dependencies do not participate. And a dependency cannot observe the response: by the time a status code or a body exists, the graph's inbound half is long finished and only the yield providers' exit halves remain, which run without being told what was sent. Anything that must see the outcome belongs one layer out, and the trade is laid out in middleware vs dependencies.
The corollary is worth stating plainly: the things injection is uniquely good at are the things that need a typed value derived from a matched route. Authentication, authorisation, tenancy resolution, pagination parameters and resource handles all qualify. Transport-level concerns do not.
Async and performance notes
Two costs deserve attention, and neither is the graph walk itself — that is cheap and proportional to the number of nodes.
The first is thread placement. A provider written async def runs on the event loop; a provider written as a plain def is dispatched to the AnyIO worker threadpool so it cannot stall the loop. That dispatch is protective but it draws on a bounded resource, so a synchronous provider on a hot route occupies capacity that concurrent work needs. Which provider style to reach for, and how the pool behaves under load, is developed in best practices for FastAPI dependency injection and, from the concurrency side, in async correctness and concurrency.
The second is hold time. A yield provider owns its resource for the entire request, including serialisation of the response. A database session acquired at the top of the graph is therefore unavailable to anyone else while a large response body is being rendered, which is a pooling problem rather than an injection problem — see async database sessions.
Memoisation removes duplicate work within a request but never across requests. Anything genuinely constant for the process — a parsed settings object, a connection pool, a compiled template set — should be built once at startup and merely handed out by a provider, not rebuilt each time.
Testing strategy
app.dependency_overrides is an ordinary dictionary on the application instance, keyed by the provider callable. Because it is application state and not test state, discipline about clearing it matters more than the mechanics of setting it:
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(app):
app.dependency_overrides[get_db_session] = lambda: FakeSession()
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear() # Teardown, not setup — otherwise it leaks forward.
Three habits make this reliable. Override at the narrowest node that produces the behaviour you want, so the rest of the graph still runs for real. Assert on a behaviour change rather than on the presence of a key, because a substitution keyed on the wrong import path installs silently and does nothing. And when the provider you are replacing yields, the replacement must yield too, or the surrounding commit and close logic is never exercised. A wider treatment of testing an app end to end lives in testing FastAPI applications.
Failure modes and diagnosis
A provider evaluates twice and you cannot see why. Compare identities, not names. dep_a is dep_b returning False for two callables with the same __name__ is the whole explanation, and factory functions that build and return a closure are the usual source.
Startup dies with a partially initialized module. A router imports a service that imports the router. The traceback names whichever module was entered second, which means the file it blames changes depending on whether the process was started by your server command or by the test runner. The durable repair is to invert the direction with a protocol bound in the factory, which is walked through in fixing FastAPI dependency injection circular imports.
Connections leak under load. A provider that opens a resource with return rather than yield has no exit half, so nothing releases it. Convert it to a generator and put the release in finally.
A test passes alone and fails in a full run. An override installed by an earlier test is still in place, because the app fixture outlives the test. Clear in teardown, or build a fresh app per test.
An authorisation gate does not cover a new endpoint. The endpoint was registered on the app or on a different router. Group by trust level rather than by convenience, and add a test that walks the route table asserting every route carries the gate.
A provider blocks the loop intermittently. A plain def provider doing network I/O is offloaded, but an async def provider doing the same work with a synchronous client is not. Audit provider bodies for synchronous clients and file access.
Choosing an attachment site
FastAPI(dependencies=) | APIRouter(dependencies=) | Handler parameter | |
|---|---|---|---|
| Value available to the handler | No | No | Yes |
| Runs on | Every route in the service | Every route on that router | Only routes that declare it |
| Execution position | First | After app-level | After router-level, in signature order |
| Appears in OpenAPI parameters | Yes, if it declares any | Yes, if it declares any | Yes |
| Replaceable in tests | Yes | Yes | Yes |
| Best for | Unconditional service-wide checks | Trust boundaries and group gates | Anything whose result you use |
| Common misuse | Blanket auth with an exemption list | Gating a health check by accident | Repeating one gate on every route |
The practical rule: if you need the value, it goes in the signature. If you need coverage over a group and the value is irrelevant, it goes on the router. The application constructor is for the rare thing that is genuinely true of every route including health checks.
FAQ
Where should I attach a dependency — the app, the router, or the handler signature? Attach it to the handler signature whenever you need its return value, because that is the only site that binds a name to it. Use the router constructor for a gate that must cover a whole group of routes, and the FastAPI constructor only for something that is genuinely unconditional across the entire service.
Why does my dependency run twice in one request? Almost always because two distinct callable objects are involved rather than one. FastAPI keys its per-request cache on the callable's identity, so a factory-produced closure, a re-export or a second import path produces a second cache entry even when the name and the source are identical.
If a dependency raises, does an earlier yield dependency still clean up? Yes. Providers that already reached their yield are unwound whether the request succeeded or failed, so a session opened before a failing authorisation gate is still released. Providers declared after the failure never run at all.
Does an override replace a dependency everywhere or only in the handler? Everywhere. Overrides are keyed by the callable, so replacing a shared node substitutes it for every consumer at any depth in the graph, including consumers you did not name in the test.
Should settings be a dependency or a module-level import?
A dependency. An imported module-level object is fixed at import time and cannot be substituted per test without environment manipulation, whereas a dependency is a seam you can replace through app.dependency_overrides.
Do dependencies run before or after middleware? After. Middleware wraps the router, so every middleware layer has already run on the way in before routing selects a handler and its dependency graph is resolved.
Related reading
- Up a level to Core Architecture and Routing Patterns for how injection sits beside routing, configuration and error handling.
- Best practices for FastAPI dependency injection covers scope selection, thread placement and how narrow a provider should be.
- Dependency caching and use_cache gives the exact cache key and when to opt out of it.
- Yield dependencies and cleanup order covers unwinding several providers together and what the endpoint's exception does to each.
- Overriding dependencies in tests covers scoping, cleanup between tests, and the silent no-op when the key is wrong.
- Fixing FastAPI dependency injection circular imports turns an import cycle into a factory-rooted tree.
- Middleware implementation explains the layer that runs before any of this, and why it is not a substitute for it.