Annotated Dependencies and Reusable Types in FastAPI

Key takeaways:

  • Annotated[User, Depends(get_current_user)] can be aliased as a type and reused as a plain hint on every route.
  • Aliases work for constrained parameters too: Annotated[str, Query(min_length=2)] becomes a named contract.
  • dependency_overrides keys off the underlying callable, so one override covers every route using the alias.
  • The default-value form breaks direct calls in tests and misleads type checkers; the Annotated form does not.
  • Aliases compose: dependencies build on dependencies, and each layer stays a one-word annotation.

This page sits under Type Hinting and IDE Integration and takes the Annotated mechanics introduced in Request Validation Patterns to their conclusion: declarations you write once and use as ordinary type hints everywhere.

All output below is real, produced by _verify/examples/val-annotated-deps.py on FastAPI 0.139.2, Pydantic 2.13.4 and Python 3.12. Because the harness cannot attach an Authorization header to its requests, that example drives the header-bearing and override cases from a /selftest endpoint that calls the same app in-process through httpx's ASGITransport and reports what genuinely came back.

A dependency alias resolving through its layers Three routes annotate a parameter with the alias AdminUser or CurrentUser. Both resolve to require_admin and get_current_user, which in turn depend on get_session and the Authorization header. A dependency_overrides entry replaces get_current_user for every route at once. GET /me GET /orders GET /admin/audit AdminUser CurrentUser get_current_user require_admin get_session Authorization header dependency_overrides keys off the callables on the right, so one entry replaces the dependency for every route using either alias.
The alias is only a name for the Annotated pair. Routes annotate with the alias; overrides target the callable it wraps.

The Problem This Solves

A mature FastAPI service ends up with the same six words on hundreds of parameters: user: User = Depends(get_current_user). Change the dependency's name and you touch every route. Add a session parameter to it and every call site is unaffected — good — but the type of user was never checked by anything, and a test that calls the handler directly receives a Depends object where it expected a User.

Aliasing the whole declaration turns it into a single word:

async def me(user: CurrentUser) -> dict[str, Any]: ...

That reads as ordinary typed Python, autocompletes as User, and carries its wiring invisibly.

How Annotated Carries the Wiring

Annotated[X, m1, m2] is a type that is X for typing purposes and additionally carries arbitrary metadata objects. Python itself ignores the metadata; consumers pick out what they recognise. FastAPI walks each parameter's annotation looking for Depends, Query, Path, Header, Cookie, Form, File and Security instances; Pydantic looks for Field instances and validator markers. They coexist without knowing about each other.

Because Annotated is just a type, it obeys every rule types obey: it can be assigned to a name, imported, parameterised in a generic, and reused. That is the entire trick.

class Session:
    """Stand-in for a database session so the example runs with no external service."""

    def __init__(self, name: str = "primary") -> None:
        self.name = name

    def load_user(self, token: str) -> User:
        return User(id=1, email=f"{token}@example.com", role="member")


async def get_session() -> Session:
    return Session()


DbSession = Annotated[Session, Depends(get_session)]


async def get_current_user(
    session: DbSession,
    authorization: Annotated[str | None, Header()] = None,
) -> User:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(401, "missing bearer token")
    return session.load_user(authorization.removeprefix("Bearer "))


# One alias, used as a plain type hint everywhere. The dependency travels with the type.
CurrentUser = Annotated[User, Depends(get_current_user)]

Note that get_current_user itself consumes DbSession. Aliases compose all the way down, and each layer reads as a normal annotation. The dependency-graph rules that govern caching and cleanup are unchanged by aliasing — they are covered in dependency caching and use_cache and yield dependencies and cleanup order.

Layering a second alias on the first

async def require_admin(user: CurrentUser) -> User:
    if user.role != "admin":
        raise HTTPException(403, f"role {user.role!r} may not do this")
    return user


AdminUser = Annotated[User, Depends(require_admin)]


@app.get("/me")
async def me(user: CurrentUser) -> dict[str, Any]:
    return user.model_dump()


@app.get("/admin/audit")
async def audit(user: AdminUser) -> dict[str, Any]:
    return {"audited_by": user.email}

Real output — unauthenticated, authenticated, and authorised-but-insufficient:

  {
    "request": "GET /me  (no Authorization header)",
    "status": 401,
    "response": {
      "detail": "missing bearer token"
    }
  },
  {
    "request": "GET /me  (Bearer ada)",
    "status": 200,
    "response": {
      "id": 1,
      "email": "ada@example.com",
      "role": "member"
    }
  },
  {
    "request": "GET /admin/audit  (member role)",
    "status": 403,
    "response": {
      "detail": "role 'member' may not do this"
    }
  },

AdminUser is one word on the route, and behind it sit an authorisation check, an authentication check, a header parse and a session. Adding a fourth layer — a tenancy check, say — means writing one function and one alias, and no route changes at all.

Reusable Constrained Types

The same technique applies to parameters that have nothing to do with dependencies:

class PageParams(BaseModel):
    page: int = Field(default=1, ge=1)
    per_page: int = Field(default=20, ge=1, le=100)


# A class used directly as a dependency; Depends() infers the callable from the annotation.
Pagination = Annotated[PageParams, Depends()]

# Reusable constrained parameter types, defined once.
SearchTerm = Annotated[str, Query(min_length=2, max_length=50, description="Full-text term.")]


@app.get("/orders")
async def orders(user: CurrentUser, page: Pagination, q: SearchTerm = "all") -> dict[str, Any]:
    return {"user": user.email, "page": page.model_dump(), "q": q}


@app.get("/public/search")
async def public_search(q: SearchTerm, page: Pagination) -> dict[str, Any]:
    # Same aliases reused on an unauthenticated route: no copy-pasted Query(...) arguments.
    return {"q": q, "page": page.model_dump()}

The constraints really are enforced on both routes:

  {
    "request": "GET /orders?page=2&per_page=200",
    "status": 422,
    "response": {
      "detail": [
        {
          "type": "less_than_equal",
          "loc": [
            "query",
            "per_page"
          ],
          "msg": "Input should be less than or equal to 100",
          "input": "200",
          "ctx": {
            "le": 100
          }
        }
      ]
    }
  },
  {
    "request": "GET /public/search?q=a",
    "status": 422,
    "response": {
      "detail": [
        {
          "type": "string_too_short",
          "loc": [
            "query",
            "q"
          ],
          "msg": "String should have at least 2 characters",
          "input": "a",
          "ctx": {
            "min_length": 2
          }
        }
      ]
    }
  },

Two things worth pointing out. Depends() with no argument infers the callable from the annotation — PageParams itself — so a Pydantic model becomes a group of query parameters with one alias, and raising the page ceiling later is a one-line change that propagates everywhere. And SearchTerm carries its description, so both endpoints document the parameter identically in OpenAPI without anyone copying prose. That consistency is what makes generated client libraries pleasant rather than merely correct; see customizing OpenAPI schema generation for the wider picture.

Why This Beats the Default-Value Form

The older syntax puts the marker where the default goes:

# Legacy: the default slot is occupied by a Depends object.
async def me(user: User = Depends(get_current_user)): ...

Four concrete problems.

Direct calls break. await me() binds user to a Depends instance, so any unit test that calls the handler as a function has to construct a User and pass it explicitly — which works, but only if the parameter has no other defaults competing for position. With Annotated, the signature is honest: parameters with defaults have real defaults, and the function is callable.

Type checkers are lied to. user: User = Depends(...) claims a User default that is not a User. Tools either special-case FastAPI or emit a false positive on every route in your codebase.

Parameter ordering gets awkward. Because every dependency occupies a default slot, a single non-default parameter must come first, and you end up reordering arguments for reasons that have nothing to do with readability.

It cannot be reused. A Depends in a default slot is welded to that parameter. Annotated is a type, and types have names.

The default-value form is not deprecated and existing code does not need rewriting. But the migration is mechanical and can be done route by route: move the marker into Annotated, and put the real default (or nothing) in the default slot.

Testing: Overrides Target the Callable

The most common worry about aliases is that they hide the dependency from dependency_overrides. They do not — the alias holds a reference to the same function object, so the override key is unchanged:

app.dependency_overrides[get_current_user] = lambda: User(
    id=99, email="admin@example.com", role="admin"
)

Real output, with no Authorization header on any of these requests:

  {
    "request": "GET /me  (no header, get_current_user overridden)",
    "status": 200,
    "response": {
      "id": 99,
      "email": "admin@example.com",
      "role": "admin"
    }
  },
  {
    "request": "GET /admin/audit  (override supplies an admin)",
    "status": 200,
    "response": {
      "audited_by": "admin@example.com"
    }
  },
  {
    "request": "GET /me  (after clearing overrides)",
    "status": 401,
    "response": {
      "detail": "missing bearer token"
    }
  }

The middle entry is the interesting one. /admin/audit uses AdminUser, which depends on require_admin, which depends on CurrentUser. Overriding only get_current_user — the innermost function — made the real require_admin run against a fabricated admin and pass. That is exactly the behaviour you want in tests: substitute at the boundary, exercise your actual authorisation logic.

The last entry confirms the override was cleared. Overrides are global mutable state on the app, so clear them in a fixture teardown or one leaked override will make an unrelated test suite pass for the wrong reason. The fixture patterns are in overriding dependencies in tests.

For tests that do not need the HTTP layer at all, aliased handlers can simply be called:

import pytest


@pytest.mark.asyncio
async def test_audit_rejects_members():
    with pytest.raises(HTTPException) as exc:
        await require_admin(User(id=1, email="a@b.com", role="member"))
    assert exc.value.status_code == 403

That test never builds an app, a client or a request. It is possible only because require_admin takes user: CurrentUser, which at runtime is just User.

Where to Put the Aliases

Import direction is the thing to protect. Two rules avoid the circular imports described in the dependency injection circular import fix:

Dependency aliases live beside their dependency function. CurrentUser belongs in the module that defines get_current_user — usually a dependencies.py or security.py. Routers import from it; it imports from no router.

Pure constrained types live in a shared types module. SearchTerm, PositiveInt, Slug have no dependencies and no I/O, so a leaf types.py that everything may import is safe by construction. This is the same module where the reusable validators from creating reusable custom validators belong.

Name aliases for what they are, not for how they are obtained: CurrentUser, not UserFromToken. The point of the alias is that the route does not care where the value came from.

Trade-offs and When Not To

Indirection has a cost. A reader who has never seen CurrentUser has to jump to its definition to learn that it authenticates. That is one jump, and IDEs make it a keystroke — but it is real, and it argues for a small number of well-named aliases rather than one per parameter.

Do not alias a one-off. A dependency used by a single route is clearer inline. The alias earns its keep at the third call site.

Aliases do not change the dependency graph. Two aliases wrapping Depends(get_session) are still the same dependency and are still cached within a request. Equally, aliasing does not merge two distinct callables that happen to do the same thing — caching is keyed by the callable object.

Annotated requires Python 3.9+ for typing.Annotated, and 3.10+ for the X | None syntax used throughout. On older interpreters, typing_extensions.Annotated and Optional[X] work identically.

FAQ

Why is Annotated better than putting Depends in the default value? Because the default slot then holds a Depends object rather than a real default, which breaks direct calls in tests and confuses type checkers. With Annotated the metadata lives in the type, so the parameter's declared type is the type you actually receive and the function stays an ordinary callable.

Does aliasing a dependency as a type change how dependency_overrides works? No. The override key is the underlying callable, not the alias. Overriding get_current_user replaces it for every route that uses the CurrentUser alias, because the alias is just a reference to the same function object.

Can I stack several pieces of metadata in one Annotated? Yes. Annotated accepts any number of metadata objects and FastAPI reads the ones it recognises while Pydantic reads its own. A single alias can carry a Query marker, a Field constraint and a custom validator at once.

Should a reusable annotated type live next to the model or in its own module? Put dependency aliases in the module that owns the dependency function, and pure constrained types in a shared types module. That keeps import direction one-way and avoids the circular imports that come from a module importing routes to get an alias.

Does using Annotated aliases cost anything at runtime? No measurable amount. FastAPI inspects signatures once at import time to build the dependency graph, so an alias reused on forty routes resolves to the same metadata objects and adds no per-request work.