Overview

Depends supplies per-request resources: a database session, the current user, settings. The lifespan context manager owns resources that live as long as the worker process: the connection pool, a shared HTTP client, a loaded model. Keep the two scopes separate and let dependencies read what lifespan created. The framework baseline is in fastapi.

Keep routes thin and chain dependencies

A route declares what it needs and delegates. Fetching, access control, and resource setup live in dependencies that can be tested alone. FastAPI resolves the dependency graph per request and calls each dependency once, even when several others declare it.

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
    return await auth_service.resolve(db, token)
 
async def get_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
    if not user.is_admin:
        raise HTTPException(status_code=403)
    return user

Name type-plus-dependency pairs once with Annotated and reuse them in signatures:

CurrentUser = Annotated[User, Depends(get_current_user)]
DB = Annotated[AsyncSession, Depends(get_db)]
 
@router.get("/profile")
async def profile(user: CurrentUser, db: DB) -> UserRead: ...

Mind when yield cleanup runs

Code after yield runs after the response is sent by default (scope="request"). A commit() there cannot change the response, so the client sees success even if the commit fails. Either commit in the route or service and keep only the close in the dependency, or end the dependency before the response is sent with scope="function".

async def get_db(factory=Depends(get_session_factory)) -> AsyncGenerator[AsyncSession, None]:
    async with factory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
 
DB = Annotated[AsyncSession, Depends(get_db, scope="function")]  # commit runs before the response

If you catch an exception in a yield dependency, re-raise it unless you raise an HTTPException. Do not hand a request-scoped session to a background task; open a new session inside the task (fastapi-background-tasks).

Use a class for configurable dependencies

A class with __call__ is clearer than a closure factory. The instance is created once and shared across requests, so keep per-request state out of it.

class RateLimiter:
    def __init__(self, max_calls: int, window_seconds: int) -> None:
        self.max_calls, self.window_seconds = max_calls, window_seconds
 
    async def __call__(self, request: Request) -> None:
        if await exceeds_limit(f"rl:{request.client.host}", self.max_calls, self.window_seconds):
            raise HTTPException(status_code=429)
 
@router.post("/payments", dependencies=[Depends(RateLimiter(10, 60))])
async def create_payment(...) -> PaymentRead: ...

Override dependencies in tests

app.dependency_overrides[get_db] = lambda: test_session
# teardown
app.dependency_overrides.clear()

Clear the overrides after each test so state does not leak. See python-testing.

Create app-wide resources in lifespan

@app.on_event("startup") and shutdown are deprecated. If you pass lifespan, the event handlers are no longer called. Lifespan runs once per worker process, and only for the main app, not mounted sub-applications. Code before yield is startup; code after is shutdown.

@asynccontextmanager
async def lifespan(app: FastAPI):
    engine = create_async_engine(settings.database_url, pool_size=10, pool_pre_ping=True)
    async with engine.connect() as conn:
        await conn.execute(text("SELECT 1"))          # fail fast if the database is unreachable
    async with httpx.AsyncClient(timeout=10.0) as http:
        yield {"session_factory": async_sessionmaker(engine, expire_on_commit=False), "http": http}
    await engine.dispose()
 
app = FastAPI(lifespan=lifespan)
 
def get_session_factory(request: Request):
    return request.state.session_factory
  • Yield a dict from lifespan to expose state as request.state; wrap access in a dependency so routes do not touch the app object and tests can override it.
  • pool_pre_ping=True discards stale connections after a database restart. Pool sizes multiply per worker; see fastapi-async-io and postgres.
  • Load heavy resources (ML models, indexes) before yield so failures surface at startup, not on the first request. A worker that cannot load its model should crash and let the process manager restart it.
  • An exception raised before yield stops startup. Wrap partially created resources in try/finally so they are released.