Dependencies and injection
Article.
Depends is FastAPI’s defining feature. It resolves a graph per request,
caches within it, guarantees teardown, and it is the seam that makes the
application testable without patching. Most of its behaviour follows from
when the graph is built and how it is walked.
Your session dependency commits after yield and the commit fails. What
does the client see? On current FastAPI, a 201. Every output below is from
FastAPI 0.143.0 on Python 3.14, with an ASGI wrapper logging the moment the
response is actually sent.
A dependency is any callable
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as s:
yield s # no commit here: see below
@app.post("/orders", status_code=201)
async def create(
body: OrderIn,
session: Annotated[AsyncSession, Depends(get_session)],
):
order = await orders.create(session, body)
await session.commit() # before the response
return orderEverything before yield is setup, everything after is teardown, and the
teardown runs whether the handler returned or raised. Annotated[T, Depends(f)] is the current form; session: AsyncSession = Depends(get_session) still works but puts a runtime value in a default slot.
The graph is built at startup
When a route is registered, FastAPI inspects its signature once and builds a tree of nodes, recursively, because a dependency’s own parameters are inspected the same way.
Each parameter is classified by its annotation:
Depends/Security become child nodes; Request, Response,
BackgroundTasks and SecurityScopes are injected specially; a name in the
path template is a path parameter; a Pydantic model is the body; a plain
scalar is a query parameter.
route: POST /orders
├── app-level dependencies
├── router-level dependencies
├── decorator dependencies=[...]
└── create(body, session)
├── body -> parse and validate OrderIn
└── session -> get_session()Resolution order, and why it is sequential
app-level dependency, router-level dependency, decorator dependency,
param dependency a, param dependency b, handler, response sentThe walk is a plain loop that awaits one dependency at a time. Two independent async dependencies that each wait 100 ms:
two independent 100 ms async dependencies: 0.22 sNot 0.1 s: FastAPI does not run sibling dependencies concurrently. When two
slow lookups are independent, make one dependency that runs both in a
TaskGroup, and do not share one AsyncSession between the two tasks (a
session is not safe for concurrent use).
The per-request cache, and its key
A dependency used several times in one request runs once, and the cached value is reused:
@app.get("/cache")
def cache(x: Annotated[int, Depends(counter)],
y: Annotated[int, Depends(uses_counter)], # also depends on counter
z: Annotated[int, Depends(counter, use_cache=False)]):
...
# counter called 2 times; handler got {'x': 1, 'y': 1, 'z': 2}That is what makes one session reach the handler and everything beneath it,
so the request is one transaction. But the cache key is the callable, plus
its security scopes, plus its scope= setting, not the callable alone:
Security(get_user, scopes=["read"]) and Security(get_user, scopes=["write"])
-> get_user ran 2 times
Depends(get_session) and Depends(get_session, scope="function")
-> {'default': 1, 'function': 2} two sessions, two transactionsMixing the two scope settings for one session generator in one request
silently splits the work across two transactions.
Teardown: when it actually runs
Since FastAPI 0.118 the default teardown scope is "request": code after
yield runs after the response has been sent. 0.121 added
scope="function", which runs teardown right after the handler returns,
before the response:
scope="request" (default):
session opened, handler returns, response start sent (201),
response body sent, background task, teardown: commit
scope="function":
session opened, handler returns, teardown: commit,
response start sent (201), response body sent, background taskA commit that fails in teardown
Now make the commit in teardown fail:
| Teardown scope | Client received |
|---|---|
"request" (default) |
201, nothing persisted |
"function" |
500 |
With the default scope the client was told the order exists; the server
logged an error the client never sees. That is why the session dependency
above does not commit: commit inside the request (the handler or a service
it calls) so that a failure becomes an error response, or declare the
dependency with scope="function" if teardown must decide.
The history matters when reading older code and answers:
| Version | Teardown timing |
|---|---|
| before 0.106 | after the response and after background tasks |
| 0.106 - 0.117 | before the response; HTTPException after yield worked |
| 0.110 | exceptions swallowed in teardown no longer re-raised |
| 0.118+ | default "request": after the response again |
| 0.121+ | scope="function" available |
Nested yield dependencies tear down in reverse order of setup, like nested
with blocks.
Classes, when the dependency needs configuration
class RequireScope:
def __init__(self, scope: str):
self.scope = scope
def __call__(self, user: Annotated[User, Depends(current_user)]) -> User:
if self.scope not in user.scopes:
raise HTTPException(403, "missing scope")
return user
WRITE = RequireScope("orders:write") # created once, at importThe instance is the cache key, so two RequireScope("orders:write") objects
are different dependencies. Build them once at module level. For OAuth
scopes proper, Security(dep, scopes=[...]) accumulates required scopes down
the tree into SecurityScopes and also documents them per operation in
OpenAPI (Security and authentication).
Router-level, for anything that should be default
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])Runs on every route of the router, and its return value is discarded: it exists to raise. That makes an area secure by default rather than by remembering.
Where it breaks in production
201 for a write that was rolled back
A session dependency commits after yield; under load a serialization
failure or a dropped connection makes the commit fail. The client has its
201, retries nothing, and the order is gone. Commit before returning.
The health check that opens a database connection
A router-level Depends(get_session) applies to every route on that router,
including the liveness probe. When the database is slow, probes time out and
the orchestrator restarts pods that were fine, at exactly the moment the
database is under pressure. The liveness check must depend on nothing it
does not need.
Slow requests from sequential dependencies
Three dependencies each calling a different service add their latencies
together, because they are awaited one by one. Combine independent calls in
one dependency with a TaskGroup, or move them into the handler.
A sync dependency on every request
A def dependency runs in the threadpool, AnyIO’s 40-token limiter shared
with sync endpoints; a blocking call in it on every request queues requests
past forty concurrent (FastAPI and the event loop). Sync
yield dependencies enter in that pool too.
Overrides leaking between tests
app.dependency_overrides is global, mutable state on the app object,
consulted on every resolution. A fake set in one test stays for the next,
and tests running concurrently against one app race on it.
The test seam
app.dependency_overrides[get_session] = lambda: test_session
app.dependency_overrides[billing_client] = lambda: FakeBilling()
@pytest.fixture(autouse=True)
def _reset_overrides():
yield
app.dependency_overrides.clear()The real request path still runs, routing, validation, middleware,
serialisation, and only the leaves are swapped. Overrides key on the
original function object, so it does not matter where the symbol was
imported; the override’s own signature is injected too, so a fake may itself
declare Depends. Router-level and nested dependencies are replaced the
same way.
The trade not taken
Depends is request-scoped, and that is a real boundary. A DI container
earns its place when the same object graph is needed outside HTTP (a Celery
task, a CLI, a queue consumer), because Depends resolves nothing there.
Even then, plain constructor arguments wired in one composition root usually
beat a framework:
def build(settings: Settings) -> UserService: # the composition root
engine = create_async_engine(settings.database_url)
return UserService(repo=SqlUserRepository(engine), mailer=SesMailer())If swapping a collaborator in a test needs monkeypatch, that collaborator
is being constructed rather than injected
(Dependency Injection).
Related
Interview angle 5
- “What does
Dependsactually do?” - builds a dependency tree from each route’s signature at startup, walks it per request (app, router, decorator, then parameter dependencies, one at a time) and caches each result for the request.- Follow-up: “Two independent async dependencies take 100 ms each. How
long is the request?” - about 200 ms (0.22 s measured): they are awaited
sequentially. Combine them in one dependency with a
TaskGroup.
- Follow-up: “Two independent async dependencies take 100 ms each. How
long is the request?” - about 200 ms (0.22 s measured): they are awaited
sequentially. Combine them in one dependency with a
- “Your session dependency commits after
yieldand the commit fails. What does the client see?” - since 0.118, a 201: teardown runs after the response is sent. Measured: 201 with the default scope, 500 withscope="function". Commit before returning.- Follow-up: “What does mixing the two scopes do?” - the cache key
includes the scope, so
Depends(get_session)andDepends(get_session, scope="function")produce two sessions and two transactions.
- Follow-up: “What does mixing the two scopes do?” - the cache key
includes the scope, so
- “Why does a dependency requested twice run once?” - the per-request
cache, keyed on the callable, its security scopes and its teardown scope;
use_cache=Falseopts out.Security(get_user, scopes=["read"])andscopes=["write"]runget_usertwice. - “Why is
dependency_overridesbetter than monkeypatching?” - it keys on the function object, the whole request path still runs, and the fake can declare its own dependencies. Clear it between tests. - “How do you apply a check to a whole area?” -
APIRouter(dependencies=...): runs on every route, return value discarded. Keep it off the health check.