Dependencies and injection
Depends is FastAPI’s defining feature. It resolves a graph per request,
caches within it, guarantees teardown, and — the part that matters most — it is
the seam that makes the app testable.
A dependency is any callable
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as s:
try:
yield s
await s.commit()
except Exception:
await s.rollback()
raise
@app.post("/orders")
async def create(
body: OrderIn,
session: Annotated[AsyncSession, Depends(get_session)],
): ...Everything before yield is setup, everything after is teardown, and the
teardown runs whether the handler returned or raised. The handler never opens a
session, never closes one, and never has to remember to roll back.
Annotated[T, Depends(f)] is the current form. The older
session: AsyncSession = Depends(get_session) still works and puts a runtime
value in a default slot, which breaks reuse of the signature and confuses type
checkers.
The graph is resolved once per request
async def current_user(
s: Annotated[AsyncSession, Depends(get_session)],
token: Annotated[str, Depends(oauth2)],
) -> User:
return await s.get(User, decode(token).sub)
@app.get("/me")
async def me(
user: Annotated[User, Depends(current_user)],
# same session
s: Annotated[AsyncSession, Depends(get_session)],
): ...Dependencies compose — one can request another — and a dependency needed
twice in a request is called once, its result reused. That caching is why
get_session above yields the same session to the handler and to
current_user, which is what makes a single transaction per request work.
# opt out when you want a fresh call
Depends(make_nonce, use_cache=False)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")
@app.delete("/orders/{id}")
async def delete(user: Annotated[User, Depends(WRITE)]): ...The constructor takes the configuration, __call__ takes the injected values.
That is how one check serves many scopes without forty near-identical
functions.
Router-level, for anything that should be default
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])A dependency listed here runs for every route on the router and its return value is discarded — it exists to raise. That makes a whole area secure by default rather than by remembering, which is the difference between a policy and a hope.
| Level | Use for |
|---|---|
FastAPI(dependencies=...) |
request id, global rate limit |
APIRouter(dependencies=...) |
auth for a whole area |
| Route signature | anything the handler needs a value from |
Sync dependencies go to the threadpool
A dependency follows the same rule as a handler: def runs in the threadpool,
async def runs on the loop. So a synchronous dependency doing blocking I/O on
every request consumes a thread every request, and the pool is bounded — see
FastAPI and the event loop.
Gotcha: an exception raised after
yieldin a dependency happens once the response has already started. FastAPI cannot turn it into a 500, so it surfaces as a server error in the logs with a partially-sent response. Keep teardown boring: close things, do not do work that can fail.
Why this is worth doing: the test seam
overrides = app.dependency_overrides
overrides[get_session] = lambda: test_session
overrides[billing_client] = lambda: FakeBilling()
def test_creates_order(client):
r = client.post("/orders", json=payload)
assert r.status_code == 201The whole real request path still runs — routing, validation, middleware, serialisation, the response model — and only the leaves are swapped. Compare with monkeypatching a module attribute, which depends on where the name was imported and breaks when someone moves it.
Two rules: override by the function object, not by name, since the key is identity; and clear between tests, or one test’s fake leaks into the next.
@pytest.fixture(autouse=True)
def _reset_overrides():
yield
app.dependency_overrides.clear()Do you need a DI container?
Almost never. Depends covers the request-scoped graph, and a composition root
covers the rest:
# main.py — the only module that names concrete classes.
def build(settings: Settings) -> UserService:
engine = create_async_engine(settings.database_url)
return UserService(repo=SqlUserRepository(engine), mailer=SesMailer())A container earns its place when the same graph is needed outside HTTP —
Celery tasks, a CLI, a consumer — because Depends only resolves during a
request. Even then, plain constructor arguments wired in one place usually beat
a framework. See
Dependency Injection.
The signal is a test. If swapping a collaborator needs monkeypatch, that
collaborator is being constructed rather than injected.
Related
Interview angle 7
- “What does
Dependsactually do?” - resolves a dependency graph per request and injects the results. Dependencies can request other dependencies, and one needed twice in a request is called once with its result reused — which is how a single session reaches the handler and everything below it. - “What does a
yielddependency give you?” - setup before the handler and guaranteed teardown afterwards, so a session is opened, committed or rolled back, and closed without the handler managing any of it. - “How do you apply a dependency to a whole area?” -
APIRouter(dependencies=[Depends(require_admin)]). It runs on every route and its return value is discarded; it exists to raise. Secure by default rather than by remembering. - “How do you make a dependency take an argument?” - a class with
__call__: the constructor takes configuration,__call__takes the injected values. One permission check then serves many scopes. - “Why is
dependency_overridesbetter than monkeypatching?” - it swaps by function identity, so it does not care where the symbol was imported, and the entire real request path still runs. Clear the overrides between tests or a fake leaks forward. - “Do you need a DI container with FastAPI?” - rarely.
Dependscovers the request scope and a composition root covers construction. A container earns its place when the same graph is needed outside a request — a Celery task, a CLI, a consumer. - “What’s the trap in a
yielddependency?” - raising after theyield. The response has already started, so FastAPI cannot convert it into a 500; you get a logged server error and a partial response. Keep teardown to closing things.