Dependency injection is the feature FastAPI is built on. Route parameters, database sessions, authentication, authorization and the seams that make any of it testable are all the same mechanism. It is worth learning properly rather than by copying, because used well it lets a function signature state its own security rules.
A dependency is a function
That is the whole idea. Declare a parameter with Depends(f) and FastAPI calls
f, then passes the result:
from fastapi import Depends
from typing import Annotated
def pagination(page: int = 1, page_size: int = 20) -> dict:
return {"offset": (page - 1) * page_size, "limit": page_size}
@app.get("/listings")
def list_listings(paging: Annotated[dict, Depends(pagination)]):
return {"paging": paging}Two things happen that are easy to miss. pagination's own parameters become
query parameters on every route that depends on it —
?page=2&page_size=50 works on /listings without
list_listings mentioning either. And they appear in the OpenAPI schema, so the
documentation shows them too.
That is the pattern in miniature: a dependency is a reusable piece of a signature, not just a value.
Dependencies that clean up
The more useful form uses yield, which puts setup and teardown in one function:
def get_db() -> Iterator[Session]:
"""One session per request, always closed.
`expire_on_commit=False` above matters for FastAPI specifically: with the default, reading an
attribute off an object after `commit()` triggers a refresh query, and serialising the response
happens after the route returns — sometimes after the session is gone.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()Everything before yield runs before your route; the value is injected; everything
after runs once the response is done. The try/finally is what guarantees the session
closes even when the route raises — without it, an exception leaks a connection, and the
symptom is a pool that exhausts itself after a few hundred errors.
This is the shape to reach for whenever a resource must be released: database sessions, file handles, locks, a client that needs closing.
When the teardown actually runs
Worth knowing precisely, because one lesson later it matters. Measured on FastAPI 0.115.5:
1. dep: open
2. route body
3. dep: CLOSED <- the code after `yield`
4. background task ranTeardown happens before background tasks, not after. So a background task must not use the request's session — it is closed by then. Lesson 11 covers what that actually breaks, which is more subtle than it sounds.
Sub-dependencies
A dependency can depend on other dependencies, and FastAPI resolves the whole graph. This is StayHub's authentication, and it is the real thing:
bearer_scheme = HTTPBearer(auto_error=False)
DbSession = Annotated[Session, Depends(get_db)]
Credentials = Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)]
def get_current_user(db: DbSession, credentials: Credentials) -> User:
if credentials is None:
raise UnauthorizedException("Sign in to continue.")
claims = decode_access_token(credentials.credentials)
if claims is None or not claims.get("sub"):
raise UnauthorizedException("Your session has expired. Please sign in again.")
user = db.execute(
select(User).where(User.public_id == claims["sub"], User.deleted.is_(False))
).scalar_one_or_none()
if user is None:
raise UnauthorizedException("Your account is no longer active.")
return userget_current_user depends on get_db and on
bearer_scheme; a route depends on get_current_user. Asking for the user
gets you the session, the header parsing, the token decode and the database lookup, in the right
order, without saying so.
Two decisions in there are worth stealing.
auto_error=False means an absent header arrives as
None instead of FastAPI raising its own 403 with a different body shape. Every error
the frontends see should come from one place — one error parser, not two. Lesson 8 is about
that principle.
The user is re-read from the database on every request, never trusted from the token. A token issued an hour ago says nothing about whether the account has since been deleted or demoted. The token proves who; the database says what they currently are. That costs one indexed lookup per request and removes an entire class of "we revoked their access but they can still…" incidents.
Dependencies are cached within a request
In the graph above, get_db appears twice — once for
get_current_user and once for the route itself. It runs once. FastAPI
caches each dependency's result for the duration of a request, keyed on the callable and its
arguments.
That is not an optimisation detail; it is what makes the pattern usable. Without it, a route depending on the user and the session would get two sessions in two transactions, and writes made through one would be invisible to the other.
To opt out — for a dependency that must run fresh each time, like a nonce — use
Depends(f, use_cache=False). It is rarely what you want.
Annotated aliases, which is where this gets good
A dependency is a type plus a marker, so it can be given a name. That is the single highest- leverage thing in this lesson:
CurrentUser = Annotated[User, Depends(get_current_user)]
OptionalUser = Annotated[User | None, Depends(get_optional_user)]HostUser = Annotated[User, Depends(require_host)]
AdminUser = Annotated[User, Depends(require_admin)]Now compare a route written the long way with the same route in StayHub:
def create_property(
payload: PropertyCreateRequest,
host: User = Depends(require_host),
db: Session = Depends(get_db),
): ...def create_property(
payload: PropertyCreateRequest, host: HostUser, db: DbSession
) -> PropertyResponse:The second reads as a sentence. host: HostUser says who may call this, and it says
it in the place a reader is already looking. Multiply that by forty endpoints and the wiring
disappears from the codebase entirely.
Authorization belongs here
This is the part most tutorials skip, and it is the better half of the feature. Authentication asks "who are you?"; authorization asks "may you?" — and the second is just another dependency:
def require_host(user: CurrentUser) -> User:
"""Gate for everything under /hosts. Note it checks the flag, not a role (decision D1)."""
if not user.is_host:
raise ForbiddenException("You need a host account to do that.")
return user
def require_admin(user: CurrentUser) -> User:
if user.role != UserRole.ADMIN:
raise ForbiddenException("Staff access only.")
return userEach takes the authenticated user, checks one thing, and returns them unchanged. Composition
does the rest: require_host depends on get_current_user, so a route
asking for HostUser gets authentication and authorization from one annotation.
The gain over an if at the top of each function is real. It cannot be forgotten
— a route with no gate visibly has none. It is testable on its own, as a plain function. And
it lands in the OpenAPI schema, so the documentation shows which endpoints need a token without
anybody maintaining a list.
Notice require_host checks a flag rather than a role. In StayHub, hosting
is a mode of an ordinary account: one person books stays and lists them, and flipping the flag
never grants staff access. Roles and capabilities are different axes, and conflating them is how
"host" ends up one rename away from "admin".
A real one, end to end
Pagination is the archetypal dependency: the same three concerns on every list endpoint, validated identically, and needed as a unit. StayHub's is worth reading in full because it shows the whole pattern in fifteen lines.
First a model to hold the result, with the arithmetic attached:
class PageParams(ApiModel):
"""`?page=2&pageSize=50`, validated once and reused everywhere.
A dependency rather than three repeated `Query(...)` arguments per route. The bounds are the
point: without `le=100` a client can ask for `pageSize=1000000` and page one becomes a full
table scan serialised into memory — pagination that protects nothing.
"""
page: int = 1
page_size: int = 20
@property
def offset(self) -> int:
return (self.page - 1) * self.page_sizeThen the function that builds it from query parameters, which is where the bounds and the alias live:
def page_params(
page: int = Query(default=1, ge=1, description="1-based"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
) -> PageParams:
return PageParams(page=page, page_size=page_size)
PageQuery = Annotated[PageParams, Depends(page_params)]And then it is one word in any endpoint that needs it:
@router.get("/users", response_model=Page[AdminUserRow])
def list_users(
_: AdminUser,
db: DbSession,
page: PageQuery,
q: str | None = Query(default=None, max_length=200, description="Matches email or name"),
is_host: bool | None = Query(default=None, alias="isHost"),
) -> Page[AdminUserRow]:That signature is the endpoint's entire contract: staff only, a database session, a bounded page,
and two optional filters. page.offset and page.page_size then go straight
into the query. Add a third list endpoint and the bounds come with it — including the
le=100 that is the only thing standing between a caller and the whole table.
Dependencies that change behaviour rather than refusing
Not every gate should reject. Some endpoints do more when you are signed in and still work when you are not:
def get_optional_user(db: DbSession, credentials: Credentials) -> User | None:
"""For routes that behave differently when signed in but do not require it.
An invalid token here is treated as "anonymous", not as an error — a stale token in
localStorage should not stop someone browsing listings.
"""
if credentials is None:
return None
claims = decode_access_token(credentials.credentials)
if claims is None or not claims.get("sub"):
return None
return db.execute(
select(User).where(User.public_id == claims["sub"], User.deleted.is_(False))
).scalar_one_or_none()Same work as get_current_user, opposite failure mode: it returns None
instead of raising. That distinction matters more than it looks. A browsing page that 401s because
somebody's month-old token expired is a bug, and it is the kind that only shows up for real users.
Having both dependencies makes the choice explicit at each route rather than accidental.
Async dependencies
A dependency can be async def, and FastAPI handles the mix without you thinking
about it: async dependencies are awaited, plain ones run in the threadpool, and either
kind can be used by either kind of route.
async def get_async_db() -> AsyncIterator[AsyncSession]:
"""One async session per request, always closed. The async twin of `get_db`."""
async with AsyncSessionLocal() as session:
yield sessionNote async with replacing try/finally — the context manager
already guarantees the close. The one rule: do not make a dependency
async def and then block inside it. An async dependency runs on the event
loop, so a synchronous database call in there stalls every other request in the process. Lesson 12
is entirely about that.
Dependencies that return nothing
Sometimes you need a check to run but have no use for its result. Put it on the decorator instead of in the signature:
@router.get("/reports", dependencies=[Depends(require_admin)])
def reports(db: DbSession):
...The dependency runs and can still raise; its return value is discarded. The same list works on
an APIRouter, applying to every endpoint under it, and on FastAPI()
itself, applying to everything.
Global dependencies are worth using sparingly and never for authorization. A rule declared once
at application level is invisible from the forty files it governs, and the day somebody adds a
public endpoint they will not know it was there. StayHub repeats _: AdminUser in each
admin signature deliberately — the repetition is cheaper than the surprise.
Classes as dependencies
Any callable works, so a class does too. Its __init__ parameters become the
dependency's parameters:
class RateLimit:
def __init__(self, per_minute: int) -> None:
self.per_minute = per_minute
def __call__(self, user: CurrentUser) -> None:
if exceeded(user.id, self.per_minute):
raise ApiException("Slow down.", status_code=429)
@router.post("/expensive", dependencies=[Depends(RateLimit(per_minute=5))])
def expensive(...): ...The instance is created once at import time and called per request, which is what lets the dependency be configured — different limits on different routes from one class. This is the right shape whenever a dependency needs parameters that are not request data.
Overrides, and what makes any of this testable
Every dependency is a seam. app.dependency_overrides is a plain dict that swaps one
callable for another:
@pytest.fixture
def client(db, admin) -> TestClient:
"""A client whose requests run inside the test's transaction, authenticated as `admin`."""
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_current_user] = lambda: admin
yield TestClient(app)
app.dependency_overrides.clear()Two overrides and the test has a real HTTP client that runs inside a transaction it will roll back, authenticated as a user it created, with no login, no password hashing and no token. The application code is untouched — there is no test flag anywhere in it.
It also makes authorization cheap to test properly. Swap in a plain customer and the same route must now refuse:
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_current_user] = lambda: customer
try:
assert TestClient(app).get("/api/v1/admin/users").status_code == 403
finally:
app.dependency_overrides.clear()Two ways to get this wrong
The dict is keyed on the function object, not its name. Import
get_db from a different module than the application did and you are keying on a
different object, so the override silently does nothing. The test then hits the real dependency,
which usually fails in a way that looks like a bug in the route. There is no error and no warning
— the key just never matches.
The same trap catches people overriding the wrong level. StayHub's routes depend on
CurrentUser, which is built from get_current_user — so the override
targets get_current_user, the underlying function. Overriding the alias is not a
thing; the alias is a type.
Always clear. The dict lives on the app object, which is
module-level and shared by every test file in the run. A leftover override leaks into unrelated
tests as a phantom failure — usually somewhere far away, usually blamed on test ordering.
Clear it in a fixture teardown or a finally, never at the end of the test body where a
failed assertion skips it.
Dependencies can read the path too
A dependency sees the same request your route does, path parameters included. That enables a pattern worth knowing: load-or-404 as a dependency, so the route never handles the missing case.
def get_owned_listing(public_id: UUID, host: HostUser, db: DbSession) -> Property:
"""The listing at {public_id}, if the caller owns it. 404 otherwise."""
prop = PropertyRepository(db).get_by_public_id_full(public_id)
if prop is None or prop.deleted or prop.host_id != host.id:
raise NotFoundException("Listing not found.")
return prop
OwnedListing = Annotated[Property, Depends(get_owned_listing)]
@router.patch("/{public_id}", response_model=PropertyResponse)
def update_property(listing: OwnedListing, payload: PropertyUpdateRequest, db: DbSession):
...public_id is declared on the dependency, and FastAPI takes it from the
path because the route's URL contains {public_id}. The route itself never mentions it.
Three endpoints written this way share one definition of "exists, not deleted, and yours", which
is the kind of rule that otherwise drifts between copies — one of them forgetting the
deleted check, quietly, for a year.
The catch: the dependency must run on every request to that route, so this is wrong when the lookup is expensive and only sometimes needed. It is also worth keeping such a dependency mechanical. The moment it starts deciding what to do with the listing rather than just producing it, the logic has escaped into the web layer.
Security() and scopes
Security() is Depends() with one extra ability — it carries
scopes, and FastAPI puts them in the OpenAPI document:
from fastapi import Security
@router.delete("/{public_id}")
def delete_listing(user: Annotated[User, Security(get_current_user, scopes=["listings:write"])]):
...Scopes come into their own with OAuth2 and third-party clients, where the token itself carries a list of what its bearer was granted. For a first-party API where your own frontend is the only client, they usually add ceremony without adding safety — StayHub uses plain role dependencies for exactly that reason. Reach for scopes when tokens are issued to parties you do not control.
Raising from a dependency
A dependency raises like anything else, and the exception is handled by the same machinery.
StayHub's raise domain exceptions rather than HTTPException:
raise ForbiddenException("You need a host account to do that.")That keeps the dependency usable outside the web layer and keeps every error body identical — lesson 8 is about the handler that makes it so.
One behaviour to know: a dependency that raises stops the chain. The route never
runs, and neither do dependencies that had not been resolved yet. But anything that already yielded
still gets its teardown, so a session opened before an authorization check failed is still closed.
That is try/finally earning its place.
What not to put in a dependency
The mechanism is pleasant enough that it gets over-used. Three cases where it is the wrong tool.
Business logic. A dependency that computes a price or decides whether a booking is allowed has moved a rule out of the service layer into the web layer, where nothing else can reach it. Dependencies are for acquiring things and gating access.
Work that should be conditional. Dependencies always run. A dependency that loads an expensive object the route only sometimes needs pays for it on every request. Inject the session and load it when required.
Anything slow and shared. A dependency that opens an HTTP client per request is
building a connection pool it throws away. Create it once at startup in the
lifespan, hold it on app.state, and have the dependency hand it out.
Dependencies at startup, not per request
The most common performance mistake with dependencies is building something expensive in one.
# ⚠️ a new connection pool per request, thrown away at the end of it
async def get_http() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=10)Create it once in the lifespan, hold it on app.state, and let the dependency hand it
out:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(timeout=10)
yield
await app.state.http.aclose()
def get_http(request: Request) -> httpx.AsyncClient:
return request.app.state.http
Http = Annotated[httpx.AsyncClient, Depends(get_http)]The dependency is now a lookup rather than a construction, the connection pool is reused, and shutdown closes it properly. The same shape applies to anything with a pool or a handshake — Redis, a message producer, a cloud SDK client.
The database session is the deliberate exception: it must be per request, because a transaction
is per request. What is shared is the engine, created once at import, and
SessionLocal is a cheap factory over it.
What the graph costs
Dependencies are resolved per request, and the resolution itself is cheap — FastAPI inspects signatures once at startup and reuses the plan. What is not free is what the dependencies do.
StayHub's CurrentUser costs one indexed database lookup on every authenticated
request, which is a deliberate trade for immediate revocation. Stacking four dependencies that each
query is how a route acquires four queries nobody wrote.
The caching rule is what keeps that manageable: a dependency needed by three others runs once. If you find yourself avoiding a dependency because "it would query again", check — it almost certainly would not.
The short version
| You want | Write |
|---|---|
| A reusable value | a function, Depends(f) |
| Setup and teardown | yield inside try/finally |
| A readable signature | Annotated[T, Depends(f)] given a name |
| Authentication | a dependency returning the user |
| Authorization | a dependency taking the user and raising |
| A check with no value | dependencies=[Depends(f)] on the decorator |
| A configured dependency | a class with __init__ and __call__ |
| A test seam | app.dependency_overrides[f] = fake, then clear |
Next: SQLAlchemy, sessions and migrations — what
get_db is actually handing out, and the data layer it belongs to.