FastAPI Dependencies: Where Shared Logic Should Actually Live
FastAPI's Depends system is where shared logic should live. Here's how yield-based dependencies, scoping, and sync-vs-async choices work in practice — and where the pattern breaks down.
12 Mar 2026, 20:49 UTC

Every FastAPI codebase starts the same way: a few endpoints, each opening its own database session, checking its own auth header, maybe duplicating a pagination parser. By the tenth endpoint you have five slightly different copies of the same session-handling code, and a bug fix in one of them never quite reaches the others. FastAPI's dependency injection system — the Depends mechanism — exists precisely to stop that drift, and the useful takeaway is this: treat dependencies as the single place where resource lifecycle and cross-cutting request logic live, and your path operations shrink to pure business logic.
What Depends actually does
When you write db: Session = Depends(get_db) in a path operation, FastAPI calls get_db for you, injects the result, and — critically — caches it for the duration of that request. If three different dependencies in the same request all declare Depends(get_db), get_db runs once and all three receive the same object. That per-request caching is what makes dependency graphs safe to compose.
Dependencies can themselves declare dependencies, forming a directed acyclic graph. A typical chain looks like: get_db → get_current_user (needs the db to look up a token) → require_admin (needs the user). FastAPI resolves the graph in order, bottom-up, before your endpoint code runs.
The yield pattern: setup and teardown in one function
The most valuable and most underused feature is that a dependency can be a generator. Code before yield runs before the request; code after it runs after the response is sent, even if the endpoint raised an exception. This gives you guaranteed cleanup without middleware.
# dependencies.py — runs inside your FastAPI app, no special permissions needed
from collections.abc import Iterator
from sqlalchemy.orm import Session
from .database import SessionLocal # your session factory
def get_db() -> Iterator[Session]:
db = SessionLocal()
try:
yield db
finally:
db.close()
# routers/users.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .dependencies import get_db
router = APIRouter()
@router.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
To verify cleanup works, add a temporary print("closing session") after the yield, hit the endpoint once with curl http://localhost:8000/users/1, and confirm the message appears in your server logs after the response returns. Also test the failure path: trigger the 404 and confirm the session still closes. That second check is the one people skip.
Choosing the scope: app, router, or operation
Dependencies attach at three levels, and picking the right one matters more than the syntax:
- Path operation level — logic specific to one endpoint, like parsing a niche query parameter.
- Router level —
APIRouter(dependencies=[Depends(verify_token)])applies auth to every route in that router without repeating it. Use this for route groups that share a security boundary. - App level —
FastAPI(dependencies=[...])for truly global concerns like rate limiting.
Note the distinction: router- and app-level entries in dependencies=[...] run for their side effects (like rejecting unauthorized requests) and their return values are discarded. If you need the returned value — say, the current user — declare it in the path operation signature instead.
Sync vs async: the event loop trap
FastAPI inspects your dependency's definition. An async def dependency runs directly on the event loop; a plain def dependency is offloaded to a thread pool so it doesn't block other requests. The trap: if you write def get_db() but call a blocking I/O library inside an async def endpoint directly (bypassing the dependency), you block the loop. Keeping blocking work inside def dependencies is the idiomatic escape hatch — FastAPI threads it for you. The trade-off is thread-pool overhead and the fact that thread pool size is finite; under heavy load with many slow sync dependencies, requests queue. If your database driver has a real async implementation (e.g., asyncpg, httpx.AsyncClient), prefer async def dependencies end to end.
Limitations worth knowing before you commit
Deep nesting is the real cost. A chain of A → B → C → D is easy to write and miserable to debug, because the request flow lives across four files and the traceback only shows the failure point. Two or three levels is a healthy ceiling in practice. Circular dependencies fail outright — FastAPI will raise an error when the graph can't resolve — so keep the graph a tree. Finally, remember that dependency parameters show up in the generated OpenAPI schema: a dependency that declares query or header parameters documents them automatically in Swagger UI, which is great until a "helper" dependency silently adds a required parameter to twenty endpoints.
Where to start
Pick the one piece of logic you've copy-pasted the most — usually the database session — convert it to a yield dependency, and verify the teardown fires on both success and error paths. Then move auth to the router level. Those two moves alone eliminate most of the duplication the DI system was built to solve, and they give you a concrete pattern to follow before you build anything more elaborate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.