A dependency can declare its own dependencies. Those can declare more. FastAPI walks the whole tree, builds everything in the right order, and threads the results through.
In simple language — Depends() composes. We chain small focused dependencies (read header → decode token → fetch user → check role) and each handler just declares the top of the chain.
The chain
A common auth pipeline. Each layer has one job:
from fastapi import Depends, Header, HTTPException
def get_token(authorization: str = Header(...)) -> str:
if not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
return authorization.removeprefix("Bearer ")
def get_current_user(token: str = Depends(get_token)) -> User:
user = decode_and_lookup(token)
if not user:
raise HTTPException(401, "invalid token")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(403, "admin only")
return user
@app.delete("/users/{user_id}")
def delete_user(user_id: int, admin: User = Depends(require_admin)):
return {"deleted_by": admin.email}
Our handler asks for require_admin. FastAPI sees it needs get_current_user, which needs get_token, which needs the Authorization header. The whole chain runs in order. Any layer raising stops the request.
The dependency graph
For a single endpoint:
delete_user handler
require_admin
get_current_user
get_token
Authorization header
Per request, top to bottom: read header, decode token, fetch user, check role, run handler. Each layer is independently testable.
Request-scoped caching — the multi-edge case
When the dependency graph is a tree, things are simple. When it’s a diamond — the same dep used by multiple branches — caching matters.
Imagine the handler depends on get_current_user AND uses require_admin (which also depends on get_current_user). Without caching we’d call get_current_user twice — meaning two DB lookups for the same user.
FastAPI caches every dependency per request. The first call runs it; every subsequent reference returns the cached value.
get_current_user called onceget_current_user — runs once
@app.get("/admin/dashboard")
def dashboard(
user: User = Depends(get_current_user), # uses cached result
admin: User = Depends(require_admin), # require_admin also calls get_current_user, cached
):
# user and admin are literally the same object
assert user is admin
return {...}
This is enormous in practice. We stitch together small dependencies fearlessly because shared sub-deps don’t multiply work.
Opting out of the cache
Rarely we want a fresh call every time the dep is referenced — for instance, a “request ID” generator that should produce a new UUID per slot. Set use_cache=False:
def fresh_uuid() -> str:
return str(uuid4())
@app.get("/trace")
def trace(
a: str = Depends(fresh_uuid, use_cache=False),
b: str = Depends(fresh_uuid, use_cache=False),
):
return {"a": a, "b": b} # different UUIDs
Default is use_cache=True and that’s almost always what we want.
The scope of “request”
The cache lives for one HTTP request — start to response. The next request starts with an empty cache. There’s no cross-request caching at the DI level (use Redis or a module-level cache for that).
This per-request scope is also why DB sessions work cleanly with Depends — same session for the whole request, fresh session for the next one.
Composition wins
Sub-dependencies turn DI into a programming model. Auth check, feature flag check, tenant scoping, current org lookup — each is a five-line function. Endpoints declare only the top of the chain. New endpoint? Add Depends(require_admin) and we’re done.
The takeaway — Depends() composes recursively. FastAPI resolves the whole graph per request and caches each node. We get small, testable, composable middleware-without-middleware.