FastAPI Dependency Injection with Depends: Annotated, Yield and Overrides (2026)

Web Development Nov 06, 2026

FastAPI Dependency Injection with Depends: Annotated, Yield and Overrides (2026)

Almost every FastAPI app needs the same building blocks on many routes: query filters, the current user, a database session, feature flags. Copying that setup into each path operation gets messy fast. Dependency injection with Depends is FastAPI's answer: you write a small callable once, declare it on the route (or on another dependency), and FastAPI calls it, validates its parameters, caches the result for the request and injects the return value. In this tutorial we share query parameters across routes with the modern Annotated[..., Depends(...)] style, chain sub-dependencies for auth, open and close a fake database with yield, and swap a settings dependency in tests with app.dependency_overrides. Everything was tested on Python 3.13.5 with FastAPI 0.162.0, Starlette 1.10.0, Pydantic 2.14.3 and httpx 0.28.1 (used by TestClient), and every output block below is the real output of the scripts shown.

Why Depends in 2026?

  • One place for shared logic: pagination, auth headers and DB sessions live in reusable callables instead of copy-pasted route code.
  • Request-scoped cache: the same dependency declared twice in one request runs once; sub-dependencies are solved automatically.
  • Cleanup with yield: code after yield runs after the response path, so sessions and connections close even when the handler raises.
  • Testable: app.dependency_overrides replaces production callables without starting a real database or external API.
  • Annotated style: Commons = Annotated[dict, Depends(common_parameters)] keeps signatures readable and works with type checkers.

Install

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install fastapi==0.162.0 httpx
# TestClient ships with FastAPI / Starlette; httpx is its HTTP transport

We drive every example with fastapi.testclient.TestClient, so you do not need a running server. The same dependencies work unchanged under uvicorn in production.

Step 1: Share query parameters with Annotated and Depends

"""Step 1: shared query params via Depends + Annotated."""
import sys
from typing import Annotated

from fastapi import Depends, FastAPI, Query
from fastapi.testclient import TestClient
import fastapi

print("Python", sys.version.split()[0])
print("fastapi", fastapi.__version__)

app = FastAPI(title="DI commons demo")


def common_parameters(
    q: Annotated[str | None, Query(max_length=50)] = None,
    skip: Annotated[int, Query(ge=0)] = 0,
    limit: Annotated[int, Query(ge=1, le=100)] = 10,
) -> dict:
    return {"q": q, "skip": skip, "limit": limit}


Commons = Annotated[dict, Depends(common_parameters)]


@app.get("/items/")
def list_items(commons: Commons) -> dict:
    items = [f"item-{i}" for i in range(commons["skip"], commons["skip"] + commons["limit"])]
    if commons["q"]:
        items = [x for x in items if commons["q"] in x]
    return {"params": commons, "items": items, "count": len(items)}


@app.get("/users/")
def list_users(commons: Commons) -> dict:
    return {"params": commons, "users": [f"user-{i}" for i in range(commons["limit"])]}


client = TestClient(app)
r1 = client.get("/items/", params={"skip": 2, "limit": 3})
r2 = client.get("/items/", params={"q": "item-2", "skip": 0, "limit": 5})
r3 = client.get("/users/", params={"limit": 2})
print("status_items", r1.status_code, r1.json())
print("status_filter", r2.status_code, r2.json())
print("status_users", r3.status_code, r3.json())
$ python di_commons.py
Python 3.13.5
fastapi 0.162.0
status_items 200 {'params': {'q': None, 'skip': 2, 'limit': 3}, 'items': ['item-2', 'item-3', 'item-4'], 'count': 3}
status_filter 200 {'params': {'q': 'item-2', 'skip': 0, 'limit': 5}, 'items': ['item-2'], 'count': 1}
status_users 200 {'params': {'q': None, 'skip': 0, 'limit': 2}, 'users': ['user-0', 'user-1']}

common_parameters is a normal function whose parameters FastAPI treats like route parameters: Query constraints still apply. We alias Annotated[dict, Depends(common_parameters)] as Commons and reuse it on /items/ and /users/. The first call returns items 2 to 4, the second filters to item-2, and the users route gets the same pagination shape without duplicating the query logic.

Step 2: Sub-dependencies and the per-request cache

"""Step 2: sub-dependencies and per-request cache."""
from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient

app = FastAPI()
CALLS: list[str] = []


def get_token(authorization: Annotated[str | None, Header()] = None) -> str:
    CALLS.append("get_token")
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="missing bearer token")
    return authorization.removeprefix("Bearer ").strip()


def get_current_user(token: Annotated[str, Depends(get_token)]) -> dict:
    CALLS.append("get_current_user")
    if token != "secret-2026":
        raise HTTPException(status_code=403, detail="invalid token")
    return {"id": 42, "name": "Amir", "role": "editor"}


def get_editor(user: Annotated[dict, Depends(get_current_user)]) -> dict:
    CALLS.append("get_editor")
    if user["role"] != "editor":
        raise HTTPException(status_code=403, detail="editors only")
    return user


@app.get("/me")
def read_me(
    user: Annotated[dict, Depends(get_current_user)],
    # same get_token sub-dependency is cached once per request
    again: Annotated[dict, Depends(get_current_user)],
) -> dict:
    return {"user": user, "same_object": user is again, "calls": list(CALLS)}


@app.get("/editor-only")
def editor_page(editor: Annotated[dict, Depends(get_editor)]) -> dict:
    return {"ok": True, "editor": editor["name"], "calls": list(CALLS)}


client = TestClient(app)
CALLS.clear()
bad = client.get("/me")
print("no_auth_status", bad.status_code, bad.json())

CALLS.clear()
ok = client.get("/me", headers={"Authorization": "Bearer secret-2026"})
print("ok_status", ok.status_code)
print("ok_body", ok.json())

CALLS.clear()
ed = client.get("/editor-only", headers={"Authorization": "Bearer secret-2026"})
print("editor_status", ed.status_code, ed.json())
$ python di_subdeps.py
no_auth_status 401 {'detail': 'missing bearer token'}
ok_status 200
ok_body {'user': {'id': 42, 'name': 'Amir', 'role': 'editor'}, 'same_object': True, 'calls': ['get_token', 'get_current_user']}
editor_status 200 {'ok': True, 'editor': 'Amir', 'calls': ['get_token', 'get_current_user', 'get_editor']}

Without an Authorization header the token dependency raises HTTPException 401 and the handler never runs. With a valid bearer token, /me declares get_current_user twice, yet the call log shows get_token and get_current_user only once: FastAPI caches the dependency result for the rest of the request, and same_object is True. The editor route adds one more layer; invalid tokens still stop at the first dependency that fails.

Step 3: Dependencies with yield for cleanup

"""Step 3: dependencies with yield (fake DB session cleanup)."""
from typing import Annotated, Iterator

from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient

app = FastAPI()
LOG: list[str] = []


class FakeDB:
    def __init__(self) -> None:
        self.closed = False
        self.rows = {1: {"id": 1, "title": "Hello Depends"}, 2: {"id": 2, "title": "Yield cleanup"}}

    def get(self, item_id: int) -> dict | None:
        return self.rows.get(item_id)

    def close(self) -> None:
        self.closed = True


def get_db() -> Iterator[FakeDB]:
    db = FakeDB()
    LOG.append("open")
    try:
        yield db
    finally:
        db.close()
        LOG.append("close")


@app.get("/posts/{item_id}")
def read_post(item_id: int, db: Annotated[FakeDB, Depends(get_db)]) -> dict:
    LOG.append(f"handler:{item_id}")
    row = db.get(item_id)
    if row is None:
        raise HTTPException(status_code=404, detail="not found")
    return {"post": row, "db_closed_during_handler": db.closed}


client = TestClient(app)
LOG.clear()
r = client.get("/posts/1")
print("ok_status", r.status_code, r.json())
print("lifecycle", LOG)

LOG.clear()
missing = client.get("/posts/99")
print("missing_status", missing.status_code, missing.json())
print("lifecycle_missing", LOG)
$ python di_yield.py
ok_status 200 {'post': {'id': 1, 'title': 'Hello Depends'}, 'db_closed_during_handler': False}
lifecycle ['open', 'handler:1', 'close']
missing_status 404 {'detail': 'not found'}
lifecycle_missing ['open', 'handler:99', 'close']

Everything before yield runs before the path operation; everything after runs afterwards (including on errors). The lifecycle log is always open → handler → close, and db_closed_during_handler is False while the handler runs. A missing post returns 404 and still closes the session. In a real app this is where you yield a SQLAlchemy Session and call session.close() in finally.

Step 4: Override dependencies in tests

"""Step 4: dependency_overrides for tests."""
from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


def get_settings() -> dict:
    return {"env": "prod", "feature_x": False}


@app.get("/feature")
def feature_flag(settings: Annotated[dict, Depends(get_settings)]) -> dict:
    return {"env": settings["env"], "feature_x": settings["feature_x"]}


client = TestClient(app)
prod = client.get("/feature")
print("prod", prod.json())


def fake_settings() -> dict:
    return {"env": "test", "feature_x": True}


app.dependency_overrides[get_settings] = fake_settings
test = client.get("/feature")
print("override", test.json())
app.dependency_overrides.clear()
back = client.get("/feature")
print("cleared", back.json())
$ python di_overrides.py
prod {'env': 'prod', 'feature_x': False}
override {'env': 'test', 'feature_x': True}
cleared {'env': 'prod', 'feature_x': False}

Production settings report feature_x: False. Assigning app.dependency_overrides[get_settings] = fake_settings flips the flag for the next request without touching environment variables. Clearing the dict restores the original callable. This is the same mechanism you use to inject a fake user or an in-memory database during pytest.

Common errors and fixes

  • Calling the dependency yourself: write Depends(get_db), not Depends(get_db()). FastAPI must receive the callable.
  • Forgetting Annotated: user: dict = Depends(get_current_user) still works, but the 2026 docs and type checkers prefer Annotated[dict, Depends(get_current_user)].
  • Cleanup never runs: use a single yield and put teardown in finally. Raising before yield skips the handler; raising after still runs other dependencies' exit code in order.
  • Overrides leak between tests: always app.dependency_overrides.clear() (or restore the previous dict) in a fixture teardown.
  • 401/403 from a dependency: that is expected. Raise HTTPException inside the dependency so every route that depends on it shares the same auth errors.

Where to use this next

Conclusion

FastAPI dependency injection turns shared setup into composable callables: declare them with Annotated and Depends, nest them for auth and permissions, use yield for reliable cleanup and override them in tests. Once those four patterns are in place, new routes stay thin and the hard parts (sessions, users, settings) stay in one tested place.

Share:
Last updated: November 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices