FastAPI – Dependency Injection in Practice

September 14, 202614 min readUpdated 8/21/2026

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 ran

Teardown 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 user

get_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 user

Each 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_size

Then 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 session

Note 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 wantWrite
A reusable valuea function, Depends(f)
Setup and teardownyield inside try/finally
A readable signatureAnnotated[T, Depends(f)] given a name
Authenticationa dependency returning the user
Authorizationa dependency taking the user and raising
A check with no valuedependencies=[Depends(f)] on the decorator
A configured dependencya class with __init__ and __call__
A test seamapp.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.