Dependencies, lifespan and settings
Read and judge FastAPI's `Depends` wiring, know what belongs in `lifespan` versus a per-request dependency, load configuration from environment variables with pydantic-settings, and override dependencies cleanly in tests.
12 cards · 6 quiz questions · 10 min read
An agent has built you a FastAPI service. Every endpoint signature is full of things like db: Annotated[Session, Depends(get_db)], there is a lifespan function at the top of main.py, and somewhere there is a Settings class — or, less happily, a hard-coded connection string. This article is about what that wiring actually does at request time, which parts are load-bearing, and the specific smells that tell you the agent got it wrong.
Depends is a function call FastAPI makes for you
Dependency injection sounds architectural. The mechanism is small: you declare a parameter whose annotation says “the value here comes from calling this function”, and FastAPI calls it before your endpoint runs and passes the result in.
from typing import Annotated
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
app = FastAPI()
def get_db():
db = SessionLocal() # borrow a connection from the pool
try:
yield db # the endpoint runs here
finally:
db.close() # always returned, even if the endpoint raises
@app.get("/responses")
def list_responses(db: Annotated[Session, Depends(get_db)], limit: int = 50):
return db.scalars(select(SurveyResponse).limit(limit)).all()
Three things to take from that. First, dependencies can themselves have dependencies, and FastAPI resolves the whole graph before your code runs. Second, each dependency’s result is cached for the duration of one request, so if four things need the settings object they share one. Third, a dependency that uses yield gives you setup and teardown: the lines after the yield run once the response has been produced.
That try/finally is not decoration. Here is the version agents commonly emit:
# Agent's version — leaks a connection on every 500
def get_db():
db = SessionLocal()
yield db
db.close() # skipped entirely if the endpoint raises
If an endpoint throws, the generator is closed by an exception thrown into it at the yield, and db.close() never runs. In dev you never notice. In production, each error leaks a pooled connection until the pool is exhausted and every request hangs waiting for one. The symptom is a service that works fine, degrades over hours, and recovers on restart — which is the most misleading failure shape there is, because restarting “fixes” it.
Prefer Annotated
You will see two styles:
# Old style: the injection lives in the default value
def list_responses(db: Session = Depends(get_db)): ...
# Current style: type position, no default, reusable
DbSession = Annotated[Session, Depends(get_db)]
def list_responses(db: DbSession): ...
The Annotated form leaves the parameter without a default, so the function is still callable directly in a test, type checkers understand it, and you can alias the annotation once and reuse it in fifty endpoints. If a change touches get_db, there is one line to edit.
Dependencies as the place you put shared rules
The pattern earns its keep when a dependency encodes a rule rather than an object. “Who is calling?” is the classic:
async def current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: DbSession,
) -> User:
user = await lookup_user(db, token)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
CurrentUser = Annotated[User, Depends(current_user)]
@app.get("/exports")
def my_exports(user: CurrentUser, db: DbSession):
...
Compare that with an agent calling a check_auth() helper inside each endpoint body. Both work, but the dependency version is declared in the signature, shows up in the OpenAPI schema your React front end reads, and can be attached to an entire router at once:
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
Now the protection is a property of the router, not of each handler. The smell to grep for is an endpoint that touches user data with no user-ish parameter in its signature and no router-level dependency — that endpoint is probably open to the internet.
lifespan: things built once, not per request
A lifespan handler runs code once when the process starts and once when it shuts down. It is an async context manager with a single yield:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(timeout=30.0) # startup
app.state.engine = create_engine(get_settings().database_url, pool_pre_ping=True)
yield # app serves requests here
await app.state.http.aclose() # shutdown
app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)
The dividing line is lifetime and safety of sharing:
Build once in lifespan | Build per request in a dependency |
|---|---|
| SQLAlchemy engine and its connection pool | A session (it holds one transaction) |
httpx.AsyncClient with keep-alive | The current user |
| A loaded model or tokeniser | A request-scoped logger with a trace id |
| A cache or queue client | Anything derived from this caller’s input |
Getting this backwards is the most common structural mistake in agent-written FastAPI. Creating the engine inside get_db means a new connection pool per request, so pooling never happens: every request pays a full TCP handshake and authentication, and your Azure Database for PostgreSQL hits its connection ceiling under modest load. The mirror-image mistake — one module-level Session shared by everything — gives you concurrent requests writing into the same transaction and reading each other’s uncommitted rows.
Two smaller notes. @app.on_event("startup") is the deprecated predecessor of lifespan; if an agent emits it, it still runs, but new code should use lifespan. And the shutdown half only runs on a graceful stop, so do not put anything load-bearing there that must happen even if the container is killed.
Config belongs in the environment
The twelve-factor rule is blunt: config is whatever differs between deployments, code is whatever does not, and the test is whether you could open-source the repository right now without leaking a credential.
# Agent's version — a live secret, in git, forever
DATABASE_URL = "postgresql://admin:Pa55w0rd@iff-db.postgres.database.azure.com/surveys"
# Better: one typed class, values from the environment
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str # required: no default, so it fails at startup
azure_openai_key: str
max_batch_size: int = 25 # optional, with a sensible default
@lru_cache # built once per process, not per request
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
pydantic-settings reads each field from the matching environment variable, coerces the type (so MAX_BATCH_SIZE=25 becomes an int, and MAX_BATCH_SIZE=lots is a startup error, not a mystery halfway through a nightly job), and refuses to construct if a required field is missing. That failure is the point: you want a container that will not start over one that starts and fails on the first survey import at 3am.
Note what the hard-coded version costs beyond the leak. The same string is used in every environment, so staging writes to production. Rotating the password needs a code change, a review and a deploy. And deleting the line does not fix it — the credential is in git history, so it must be rotated.
@lru_cache matters because Settings() reads the environment and validates on every construction. Cached, it is a per-process singleton. The catch is in tests: if you change os.environ and expect new settings, call get_settings.cache_clear(), or better, override the dependency.
Overriding dependencies in tests
Every Depends is a seam. app.dependency_overrides maps the original function object to a replacement, at any depth in the graph:
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(test_session):
app.dependency_overrides[get_db] = lambda: test_session
app.dependency_overrides[current_user] = lambda: User(id=1, email="t@example.com")
yield TestClient(app)
app.dependency_overrides.clear() # without this, the override leaks
That dictionary lives on the app object for the whole process. Setting it inside a test body and forgetting to clear it produces the worst class of test bug: passes alone, fails in the suite, or — much worse — a later test quietly talks to the real database because an override it assumed was there got removed. Always set overrides in a fixture with teardown.
What to check in your own code
grep -rn "yield"across your dependencies: is everyyieldwrapped intry/finally?grep -rn "create_engine\|AsyncClient(": does any of it sit inside a function called per request instead of inlifespan?grep -rn "os.environ\|getenv": scattered reads mean config is not centralised, and the missing-value failure will be a lateNone.grep -rniE "password|secret|key|://.*:.*@"over the repo. Anything live must be rotated, not just deleted.- Search for
on_event(— deprecated startup hooks worth migrating tolifespan. - Open the generated
/docspage and look for endpoints returning user or client data with no auth dependency in their signature. - In your test suite,
grep -rn "dependency_overrides": every assignment should have a matchingclear()in the same fixture. - Try starting the app with one required environment variable removed. It should refuse to start with a clear message naming the field.
An agent creates the SQLAlchemy engine inside the `get_db` dependency, so a new engine is built on every request. What is the main consequence?
Sources
- FastAPI — Dependencies website The Annotated + Depends style used throughout, and dependencies with yield for setup and teardown.
- FastAPI — Lifespan Events website Confirms the asynccontextmanager lifespan signature and that code before yield runs once at startup.
- FastAPI — Settings and Environment Variables website pydantic-settings BaseSettings, SettingsConfigDict(env_file=...) and the lru_cache settings dependency.
- Adam Wiggins — The Twelve-Factor App — Config website The rule that config belongs in the environment, not in code, and the litmus test for a leaked secret.