Sub-dependencies

advanced fastapi dependency-injection

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:

FastAPI resolves bottom-up
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.

Diamond graph — get_current_user called once
handler
user
require_admin
↑   ↑
get_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.