Depends gets introduced in every FastAPI tutorial as “how you get the current user,” and most developers never look past that one use case. That’s a shame, because Depends is a genuine dependency injection system — it resolves a graph, caches results per request, and manages setup/teardown lifecycles — and treating it as just a decorator for auth checks means missing the patterns that actually keep a growing FastAPI codebase testable: database sessions that clean themselves up correctly even when a handler raises, permission checks composed out of smaller checks instead of duplicated per route, and request-scoped values computed exactly once no matter how many other dependencies need them.
The flip side is that Depends is flexible enough to build genuinely bad architecture with, and the most common way that happens is treating an endpoint function as if it were a regular Python function you can just call from another endpoint to “reuse” its logic. It compiles. It even often works in a demo. It also breaks the entire model FastAPI’s dependency system was built around, and it’s the single anti-pattern that separates codebases where Depends is a productivity multiplier from ones where it’s a source of mysterious bugs. This post covers both halves: the patterns worth using deliberately, and the anti-patterns worth actively avoiding.
You’ll learn:
- How FastAPI actually resolves a
Dependsgraph, in what order, and how deeply it can nest - Why yield-based dependencies are the right tool for anything that needs guaranteed teardown
- How per-request dependency caching works, and when it silently doesn’t apply
- The right place to put shared dependencies so every router can use them without duplicating logic
- Why calling one endpoint function from another is a design smell, and what to do instead
- Class-based dependencies for parameterized, reusable checks
Table of Contents
- The Basics
- How Depends Resolution Actually Works
- Yield-Based Dependencies and Teardown
- Dependency Caching Within a Request
- Shared Dependencies Done Right
- The Anti-Pattern: Calling One Endpoint From Another
- Class-Based Dependencies
- Testing With dependency_overrides
- Common Pitfalls
- Production Best Practices
The Basics
What Depends Actually Is
Depends marks a parameter as something FastAPI should resolve for you before your function runs — by calling another function (or callable), possibly with its own Depends parameters, and passing the result in. It’s dependency injection in the same sense the term is used in any other framework: your function declares what it needs, and something else is responsible for constructing it.
from fastapi import Depends
def get_query_token(token: str) -> str:
return token
@app.get("/items")
async def read_items(token: str = Depends(get_query_token)):
return {"token": token}
This looks like a small convenience for one dependency, but it composes: get_query_token could itself depend on something else, and FastAPI resolves the whole chain, in order, before your route handler ever runs.
Why This Matters Beyond Auth
Every tutorial’s first example is get_current_user, which makes Depends look like it exists solely for authentication. In practice, the same mechanism is the right tool for database sessions, pagination parameters, feature-flag checks, request-scoped loggers, and rate limiting — anything that’s “setup work a handler needs, computed consistently, and possibly torn down afterward” is a Depends candidate, not just an auth check.
How Depends Resolution Actually Works
FastAPI builds a dependency graph per request by walking every Depends parameter recursively, resolving leaves first. Given:
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(token: str = Depends(get_query_token), db=Depends(get_db)):
return db.query(User).filter_by(token=token).first()
@app.get("/profile")
async def profile(user=Depends(get_current_user)):
return user
Calling /profile resolves get_query_token and get_db first (they have no further dependencies), then calls get_current_user with both results, and finally calls the route handler with the fully-resolved user. Nesting can go arbitrarily deep — this is a real directed acyclic graph, not a flat list — and FastAPI resolves each node exactly once per request even if multiple other dependencies need it, which is the caching behavior covered below.
Sync and Async Dependencies Can Mix Freely
A dependency can be def or async def independent of what the route handler that consumes it is. FastAPI resolves async def dependencies directly on the event loop and dispatches def dependencies to the threadpool, exactly the same split covered in Why Your FastAPI Endpoint Blocks the Event Loop for route handlers themselves. A common, correct pattern is an async def route handler with a def dependency that does something genuinely synchronous — a CPU-light config lookup, say — and FastAPI handles the thread dispatch for that one dependency automatically without you needing to match signatures across the whole chain. The one thing to watch for is the same blocking-call trap: a def dependency is safe because it’s threadpooled, but an async def dependency that calls something blocking without awaiting it blocks the loop exactly as a route handler would, since dependencies inherit the same execution rules as the handlers they feed into.
Dependencies declared directly on an APIRouter (via dependencies=[Depends(...)]) or on the app itself run for every route under them without appearing as a function parameter at all — useful for cross-cutting checks like “this whole router requires an active subscription” where the dependency’s return value isn’t actually needed by the handler.
Yield-Based Dependencies and Teardown
A dependency that needs cleanup — closing a database session, releasing a lock, committing or rolling back a transaction — uses yield instead of return. Code after the yield runs after the response has been generated, and critically, it runs even if the route handler raised an exception:
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
This is the mechanism that makes “session per request” reliably correct — the teardown isn’t something every route author has to remember to call, it’s guaranteed by the dependency itself regardless of how the handler exits. The pattern generalizes to sub-dependencies too: if get_db is itself a dependency of get_current_user, its teardown still runs after everything that depended on it has finished, in reverse resolution order — closest to a try/finally stack than a flat list of independent cleanups.
This exact pattern is the foundation for the session-scoping approach covered in depth in Async SQLAlchemy Sessions in FastAPI, Done Right — get the yield-based teardown right here and most of the session lifecycle problems in that post don’t happen in the first place.
Dependency Caching Within a Request
By default, FastAPI caches a dependency’s result for the lifetime of a single request — if two different dependencies both declare Depends(get_db), FastAPI calls get_db exactly once and hands both callers the same session object, not two separate ones:
async def get_db():
print("get_db called")
...
def dep_a(db=Depends(get_db)): ...
def dep_b(db=Depends(get_db)): ...
@app.get("/example")
async def example(a=Depends(dep_a), b=Depends(dep_b)):
# "get_db called" prints exactly once, not twice
...
This matters enormously for database sessions specifically — without this caching, dep_a and dep_b would silently operate on two different sessions within the same request, which is a subtle source of “why didn’t my write show up” bugs when one dependency writes and another reads inside the same logical request.
The caching is scoped to the request, not globally, and it can be disabled per-dependency with Depends(get_db, use_cache=False) for the rare case where you genuinely want a fresh instance even within one request. It’s also worth knowing the caching key is the callable itself — two Depends(get_db) calls share a cache entry because they reference the same function object, but two different functions that happen to do the same thing internally do not share a cache entry, even if their logic is identical.
Shared Dependencies Done Right
The natural home for dependencies used across multiple domains is a shared core/dependencies.py, as laid out in FastAPI Project Structure That Survives Growth — get_db, get_current_user, and any cross-cutting permission check belong there, imported downward into every domain router, never duplicated per router and never imported sideways between domains.
# app/core/dependencies.py
from fastapi import Depends, HTTPException, status
async def get_current_user(token: str = Depends(oauth2_scheme), db=Depends(get_db)):
user = await db.get_user_by_token(token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return user
async def require_admin(user=Depends(get_current_user)):
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return user
Notice require_admin depends on get_current_user rather than re-implementing the token check — this is composition, and it’s the actual payoff of a real dependency graph: permission logic builds on identity logic instead of duplicating it, and because of per-request caching, using require_admin and get_current_user in the same route still only resolves the user once.
The Anti-Pattern: Calling One Endpoint From Another
A tempting shortcut when a new endpoint needs “basically what another endpoint does” is to import and call that endpoint function directly:
# Anti-pattern — do not do this
@app.get("/orders/{id}")
async def get_order(id: int, user=Depends(get_current_user)):
return await fetch_order(id, user)
@app.get("/orders/{id}/summary")
async def get_order_summary(id: int, user=Depends(get_current_user)):
order = await get_order(id, user) # calling another route handler directly
return summarize(order)
This looks harmless — it even runs correctly in a lot of cases — but it breaks the model in several concrete ways. Depends parameters on the called function aren’t re-resolved through FastAPI’s graph; you’re just calling a Python function and manually threading its arguments through, so any dependency caching, error handling, or yield-based teardown FastAPI would normally coordinate is bypassed entirely for that inner call. If get_order raises an HTTPException, it propagates as a raw exception into get_order_summary’s body rather than being converted into a response the way it would if the client had actually hit /orders/{id} — meaning the two code paths handle the same error differently depending on which route you came through. And it silently couples two endpoints’ HTTP-layer signatures together, so changing get_order’s parameters for its own route now risks breaking get_order_summary in a way that has nothing to do with routing.
The fix is the layering Depends is actually meant to support: extract the shared logic into a service function that isn’t a route handler at all, and have both routes depend on or call that.
# app/orders/service.py
async def fetch_order(order_id: int, user) -> Order:
order = await db.get_order(order_id)
if order.owner_id != user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return order
# app/orders/router.py
@router.get("/{id}")
async def get_order(id: int, user=Depends(get_current_user)):
return await fetch_order(id, user)
@router.get("/{id}/summary")
async def get_order_summary(id: int, user=Depends(get_current_user)):
order = await fetch_order(id, user)
return summarize(order)
Now both routes share real logic without either one depending on the other’s HTTP-layer contract, and fetch_order is trivially unit-testable without spinning up a request at all.
Class-Based Dependencies
For a dependency that needs configuration — a pagination limit, a required permission scope — a class with __call__ gives you a parameterized, reusable dependency instead of a family of near-identical functions:
class RequirePermission:
def __init__(self, scope: str):
self.scope = scope
def __call__(self, user=Depends(get_current_user)):
if self.scope not in user.scopes:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return user
require_orders_write = RequirePermission("orders:write")
@router.post("/orders", dependencies=[Depends(require_orders_write)])
async def create_order(payload: OrderCreate):
...
This scales far better than writing require_orders_write_permission, require_payments_read_permission, and so on as separate functions — the parameterization lives in the constructor, and the FastAPI-facing shape stays a single, consistent callable pattern across every permission check in the app.
Class-based dependencies can use __call__ as a generator too, combining parameterization with yield-based teardown — useful for something like a rate limiter that needs to release a token after the request finishes:
class RateLimiter:
def __init__(self, requests_per_minute: int):
self.limit = requests_per_minute
async def __call__(self, user=Depends(get_current_user)):
token = await acquire_slot(user.id, self.limit)
try:
yield
finally:
await release_slot(token)
throttle_reports = RateLimiter(requests_per_minute=5)
@router.get("/reports", dependencies=[Depends(throttle_reports)])
async def get_reports():
...
Each instance is created once, at import time, and reused across every request — the constructor arguments configure the dependency, while __call__ still gets the full per-request resolution, caching, and teardown behavior of any other dependency.
Testing With dependency_overrides
The other major payoff of building real dependencies instead of inlining logic into handlers is app.dependency_overrides — a dict FastAPI checks before resolving any dependency, letting tests swap out get_db for a test database, or get_current_user for a fixed test user, without touching the route code at all:
# tests/conftest.py
from app.main import app
from app.core.dependencies import get_db, get_current_user
async def override_get_db():
async with TestSessionLocal() as session:
yield session
def override_get_current_user():
return User(id=1, email="test@example.com", is_admin=False)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
# tests/orders/test_router.py
async def test_create_order(client):
response = await client.post("/orders", json={"item": "widget", "qty": 2})
assert response.status_code == 200
No mocking library, no monkeypatching internals, no spinning up a real Postgres instance or a real OAuth flow just to hit an endpoint that happens to require login. This is exactly why the anti-pattern earlier in this post matters beyond code cleanliness: a route that calls another route function directly can’t be exercised this way, because the inner call never goes through FastAPI’s resolution machinery — dependency_overrides has nothing to intercept. A route that depends on a real service function through Depends, by contrast, is fully testable in isolation, with every dependency in its graph swappable independently.
Overrides also compose the same way dependencies themselves do — overriding get_db overrides it for every dependency that transitively depends on it, including get_current_user, without needing a separate override for each one. Remember to clear overrides between test modules (app.dependency_overrides.clear() in a fixture teardown) — a leftover override from one test file silently changing behavior in the next is a common source of confusing, order-dependent test failures.
Common Pitfalls
Mistake: putting business logic directly in a dependency instead of a service. A Depends function that queries, transforms, and returns a fully-processed business object makes that logic invisible to anything that isn’t a FastAPI route. Solution: keep dependencies focused on request-scoped setup (auth, sessions, pagination) and delegate real business logic to service functions.
Mistake: assuming every Depends(x) call shares a cache entry regardless of how x is referenced. Two functions that do the same thing but aren’t the same object don’t share a cache slot. Solution: import the exact same callable everywhere you want caching to apply — don’t redefine equivalent-looking dependencies in multiple places.
Mistake: forgetting yield-based teardown doesn’t run until the response is fully generated. Code after yield runs after the handler returns, which means it also runs after any other dependency further up the chain that depends on this one. Solution: don’t assume teardown ordering without tracing the actual dependency graph, especially with nested dependencies.
Mistake: calling a route handler function directly from another route. As covered above, this bypasses caching, exception handling, and teardown guarantees. Solution: extract shared logic into a plain service function neither route owns.
Mistake: overusing router-level dependencies=[...] for things the handler actually needs the return value of. If the handler needs the resolved user object, hiding it in router-level dependencies forces a second, redundant Depends(get_current_user) parameter just to get the value back. Solution: use router-level dependencies only for checks whose return value nothing needs.
Production Best Practices
- Keep dependencies request-scoped and side-effect-light. Setup, auth checks, and session management belong here; multi-step business logic doesn’t.
- Compose permission checks instead of duplicating them.
require_admindepending onget_current_useris cheaper and more consistent than reimplementing the token check per permission level. - Always pair
yieldwith atry/finallyso teardown runs even when the handler raises — don’t rely on the happy path alone. - Never call one route handler from another. If two endpoints need the same logic, that logic belongs in a service function, not in either handler.
- Use class-based dependencies once you have more than two or three parameterized variants of the same check. It keeps the permission surface area consistent and easy to audit.
Wrapping Up
Depends is a real dependency graph with caching, ordering, and lifecycle guarantees — not a decorator that happens to fetch the current user. Used deliberately, it’s what keeps auth, sessions, and cross-cutting checks consistent across a growing set of routers without duplicating logic per endpoint. Used carelessly — especially by treating a route handler as a plain function to call from elsewhere — it quietly breaks the exact guarantees that made it worth using in the first place.
Are your dependencies doing request-scoped setup, or have some of them quietly become where your business logic lives? If you’re not sure, dependency_overrides is a fast way to find out — a dependency you can’t cleanly swap out in a test is usually one that’s grown responsibilities it was never meant to have.
