How the dependency resolver works

FastAPI · Expert · 16 min

Knowing the resolver's rules explains caching surprises, override behavior, side effects on 422s and hidden latency.

When you register a route, FastAPI inspects the function signature once (get_dependant), resolving type hints and classifying every parameter: path, query, header, cookie, body, a special type (Request, WebSocket, BackgroundTasks, Response, SecurityScopes) or a sub-dependency, which is analyzed recursively. The result is a tree of Dependant objects, also used to build the OpenAPI schema. Signature analysis never happens per request.

Per request, solve_dependencies walks that tree depth-first, in declaration order, sequentially. Rules worth knowing:

  • No concurrency. Two async dependencies that each await a 100 ms call cost 200 ms. If they're independent and hot, combine them into one dependency that uses asyncio.gather.
  • Cache key = the callable (plus OAuth scopes and the dependency scope). The same function anywhere in the tree runs once. Two different lambdas or functools.partial objects are different keys, and so are two instances of a parametrized class dependency.
  • Overrides are looked up in app.dependency_overrides by the original callable at solve time, and the replacement's own signature is analyzed then, so overrides may declare different parameters.
  • Errors are collected, not short-circuited. If one dependency's parameters fail validation, that dependency isn't called, but its siblings still run. The 422 comes after resolution with all errors together. So a later dependency with side effects, like counting a rate-limit hit or opening a transaction, can run for a request that ends in 422.
  • Every sync dependency is a threadpool hop. Trivial ones (reading a header, returning a constant) are cheaper as async def.
  • Yield dependencies are entered on per-request exit stacks, "request" or "function" scoped, and unwound in reverse.

Example

from typing import Annotated
from fastapi import Depends, FastAPI, Query
from fastapi.testclient import TestClient

calls: list[str] = []

def settings():
    calls.append("settings")
    return {"rate": 10}

def parse_limit(limit: Annotated[int, Query(le=100)], s: Annotated[dict, Depends(settings)]):
    calls.append("parse_limit")
    return limit

def count_hit(s: Annotated[dict, Depends(settings)]):
    calls.append("count_hit")             # side effect!

app = FastAPI()

@app.get("/items")
def items(limit: Annotated[int, Depends(parse_limit)], _: Annotated[None, Depends(count_hit)]):
    return {"limit": limit}

c = TestClient(app)
print(c.get("/items?limit=5").json(), calls)   # {'limit': 5} ['settings', 'parse_limit', 'count_hit']
calls.clear()
print(c.get("/items?limit=500").status_code, calls)
# 422 ['settings', 'count_hit']: settings ran once, count_hit ran despite the 422

Remember

  • Signatures are analyzed once at route creation into a Dependant tree
  • Dependencies resolve sequentially, depth-first, in declaration order
  • Per-request cache key is the callable itself; overrides are looked up by it
  • Validation errors are aggregated; sibling dependencies still run before the 422

Watch out for

  • Assuming independent async dependencies run concurrently.
  • Putting side effects in a dependency and assuming they never run for invalid requests.

Check yourself

  1. Two async dependencies each await a 100 ms network call and don't depend on each other. How long does resolution take?
  2. Why might Depends(lambda: get_db()) in two places open two sessions in one request?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/tutorial/dependencies/sub-dependencies/