FastAPI’s Depends() is one of the framework’s quiet superpowers. Used well, your handlers stay clean and testable. Used poorly, you have a tangle of dependencies that’s harder to understand than what they replace. This post is the working playbook.

The basic shape

from fastapi import Depends

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/users/{id}")
def get_user(id: int, db = Depends(get_db)):
    return db.query(User).filter(User.id == id).first()

Depends(get_db) runs get_db, yields the value, runs the cleanup. Per request.

Async dependencies

async def get_db():
    async with SessionLocal() as session:
        yield session

@app.get("/users/{id}")
async def get_user(id: int, db: AsyncSession = Depends(get_db)):
    return await db.get(User, id)

Async-native. Use async with for proper cleanup.

Auth dependency chain

async def get_token(authorization: str = Header(...)):
    if not authorization.startswith("Bearer "):
        raise HTTPException(401)
    return authorization[7:]

async def get_current_user(token: str = Depends(get_token), db = Depends(get_db)):
    user = await verify_and_load(token, db)
    if not user:
        raise HTTPException(401)
    return user

async def get_admin(user = Depends(get_current_user)):
    if not user.is_admin:
        raise HTTPException(403)
    return user

@app.get("/admin/users")
async def admin_list(user = Depends(get_admin)):
    # only admins reach here
    ...

Chain dependencies. Each adds a layer.

Sub-dependencies

A dependency can have its own dependencies. Resolved automatically:

async def get_settings():
    return load_settings()

async def get_db(settings = Depends(get_settings)):
    return await connect(settings.database_url)

FastAPI builds a dependency graph; resolves once per request.

Caching within request

Same Depends(X) called twice in one request: X runs once.

async def get_user(...): ...

@app.get("/foo")
async def foo(user = Depends(get_user)): ...

# get_user only runs once even if also depended on transitively

use_cache=True is default. For “always re-resolve”: Depends(get_user, use_cache=False).

Class-based dependencies

class Pagination:
    def __init__(self, page: int = 1, limit: int = Query(10, le=100)):
        self.page = page
        self.limit = limit

@app.get("/items")
def list_items(p: Pagination = Depends()):
    return db.query(Item).offset((p.page - 1) * p.limit).limit(p.limit).all()

Useful for grouping related parameters. Especially nice when many endpoints share the same query params.

Lifespan dependencies

For app-level resources (DB pool, Redis connection):

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
    app.state.db_pool = await create_db_pool()
    app.state.redis = await aioredis.from_url(...)
    yield
    await app.state.db_pool.close()
    await app.state.redis.close()

app = FastAPI(lifespan=lifespan)

# Access via Request
async def get_redis(request: Request):
    return request.app.state.redis

App startup → resources allocated; shutdown → cleanup.

Settings dependency

from functools import lru_cache
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    secret_key: str

@lru_cache
def get_settings():
    return Settings()

@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
    return {"db": settings.database_url[:20] + "..."}

lru_cache on the factory: settings loaded once, shared across requests. See Pydantic v2 Deep Dive .

Background task injection

from fastapi import BackgroundTasks

@app.post("/signup")
async def signup(
    user_data: UserIn,
    bg: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
):
    user = await create_user(db, user_data)
    bg.add_task(send_welcome_email, user.email)
    return user

BackgroundTasks is itself a special dependency. See FastAPI Background Tasks .

Testing with dependency overrides

def test_get_user():
    app.dependency_overrides[get_db] = lambda: TestDB()
    app.dependency_overrides[get_current_user] = lambda: User(id=1, name="test")
    
    response = client.get("/me")
    assert response.json()["id"] == 1
    
    app.dependency_overrides = {}

Or with a fixture:

@pytest.fixture
def client():
    app.dependency_overrides[get_db] = lambda: TestDB()
    yield TestClient(app)
    app.dependency_overrides = {}

Cleanest way to test handlers without real DB / auth.

Multi-tenant dependency

async def get_tenant(request: Request) -> Tenant:
    tenant_id = request.headers.get("x-tenant-id")
    if not tenant_id:
        raise HTTPException(400, "missing tenant")
    return await load_tenant(tenant_id)

async def get_tenant_db(tenant: Tenant = Depends(get_tenant)) -> AsyncSession:
    async with get_session_for_tenant(tenant) as session:
        yield session

@app.get("/products")
async def products(db = Depends(get_tenant_db)):
    return await db.query(Product).all()

Tenant-scoped DB session. See Multi-Tenancy Patterns .

When DI starts to bite

  • Circular dependencies: A depends on B depends on A. Refactor.
  • Deep graphs: 10-level chains hard to follow. Flatten.
  • Mocking pain: many overrides per test. Maybe DI tax outweighs benefit.
  • Implicit ordering: dependency order not obvious.

For these cases: consider explicit constructor injection in service classes; use FastAPI DI only at the request boundary.

Class-based service pattern

class UserService:
    def __init__(self, db: AsyncSession, mailer: Mailer):
        self.db = db
        self.mailer = mailer
    
    async def create(self, data):
        user = await User.acreate(...)
        await self.mailer.send_welcome(user.email)
        return user

async def get_user_service(
    db: AsyncSession = Depends(get_db),
    mailer: Mailer = Depends(get_mailer),
) -> UserService:
    return UserService(db, mailer)

@app.post("/users")
async def create_user(data: UserIn, svc: UserService = Depends(get_user_service)):
    return await svc.create(data)

DI at the boundary; constructor injection inside services. Best of both.

Common mistakes

1. Heavy work in dependency factory

async def get_db():
    return await create_pool()  # creates pool PER REQUEST

Pool should be lifespan-scoped. Dependency just yields a session from the pool.

2. State in dependency function

counter = 0

async def get_counter():
    global counter
    counter += 1
    return counter

Module-level state across requests = race conditions. Use request.state or app.state.

3. Synchronous dependency in async handler

def get_db():  # sync
    return SessionLocal()

@app.get("/")
async def view(db = Depends(get_db)):  # blocks event loop
    ...

Match async/sync.

4. Massive dependency chain

10-deep Depends(...). Refactor into services.

5. Forgetting cleanup

def get_db():
    return SessionLocal()  # leaks; never closed

Use yield + try/finally for cleanup.

What I’d ship today

For new FastAPI apps:

  • get_db dependency for DB session (yield + cleanup).
  • get_current_user chain for auth.
  • Settings via lru_cache.
  • Lifespan for shared resources (pools).
  • Service classes for business logic.
  • dependency_overrides in tests.

Read this next

If you want my FastAPI starter (auth + DB + lifespan + service pattern), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .