Backend / Web frameworks / FastAPI / 03_dependencies_and_injection.md

Dependencies and injection

Updated 5 interview angles 7 min read source
On this page11
  1. A dependency is any callable
  2. The graph is built at startup
  3. The per-request cache, and its key
  4. Teardown: when it actually runs
  5. Classes, when the dependency needs configuration
  6. Router-level, for anything that should be default
  7. Where it breaks in production
  8. The test seam
  9. The trade not taken
  10. Related
  11. Interview angle

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

python
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 order

Everything 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.

text
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

text
app-level dependency, router-level dependency, decorator dependency,
param dependency a, param dependency b, handler, response sent

The walk is a plain loop that awaits one dependency at a time. Two independent async dependencies that each wait 100 ms:

text
two independent 100 ms async dependencies: 0.22 s

Not 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:

python
@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:

text
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 transactions

Mixing 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:

text
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 task

A 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

python
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 import

The 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

python
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

python
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:

python
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).

Interview angle 5

  • “What does Depends actually 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.
  • “Your session dependency commits after yield and 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 with scope="function". Commit before returning.
    • Follow-up: “What does mixing the two scopes do?” - the cache key includes the scope, so Depends(get_session) and Depends(get_session, scope="function") produce two sessions and two transactions.
  • “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=False opts out. Security(get_user, scopes=["read"]) and scopes=["write"] run get_user twice.
  • “Why is dependency_overrides better 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.