🎯 What You Will Learn
Dependency Injection (DI) is the feature you use in every FastAPI project — for auth, database sessions, pagination, config — but it's rarely explained on its own. In this tutorial we fix that, from zero to production habits.
- What DI actually is and the problem it solves
- Your first dependency with
Depends() - The modern
Annotatedstyle (recommended) - Sub-dependencies (dependencies that use other dependencies)
yielddependencies for setup + cleanup (DB sessions)- Class-based dependencies for reusable parameters
- Router-level and app-level dependencies
- Common mistakes and how to avoid them
Prerequisites: Python 3.11+, and you should already be comfortable creating a basic FastAPI app and a Pydantic model. If not, start with set-up-python-fast-api and python-fast-api-schema first.
fastapi-di/
├── requirements.txt
└── app/
├── __init__.py
├── main.py # routes + wiring
├── dependencies.py # our reusable dependencies
└── db.py # fake database + sessionWhat Is Dependency Injection (Plain Version)
A dependency is just something your endpoint needs before it can run: a database session, the current logged-in user, validated query parameters. Dependency injection means FastAPI gives those things to your function instead of you building them inside every handler.
You declare what you need with Depends(...), and FastAPI does three things for you: it runs the dependency, passes the result into your function, and shows it in the Swagger docs automatically.
The Problem DI Solves (See It First)
Imagine two endpoints that both need the same pagination logic. Without DI, you copy-paste the same code and the same validation into every route:
from fastapi import FastAPI, HTTPException, Query
app = FastAPI()
@app.get("/users")
def list_users(page: int = Query(1), size: int = Query(10)):
if page < 1 or size < 1 or size > 100:
raise HTTPException(status_code=400, detail="Bad pagination")
offset = (page - 1) * size
return {"page": page, "size": size, "offset": offset, "items": []}
@app.get("/orders")
def list_orders(page: int = Query(1), size: int = Query(10)):
# exact same validation copy-pasted... 😩
if page < 1 or size < 1 or size > 100:
raise HTTPException(status_code=400, detail="Bad pagination")
offset = (page - 1) * size
return {"page": page, "size": size, "offset": offset, "items": []}Your First Dependency with Depends()
Move the pagination logic into a single function. That function becomes a dependency. Both endpoints just ask for it:
from fastapi import HTTPException, Query
def pagination_params(page: int = Query(1), size: int = Query(10)) -> dict[str, int]:
if page < 1 or size < 1 or size > 100:
raise HTTPException(status_code=400, detail="Bad pagination")
offset = (page - 1) * size
return {"page": page, "size": size, "offset": offset}from fastapi import Depends, FastAPI
from app.dependencies import pagination_params
app = FastAPI(title="FastAPI DI", version="0.1.0")
@app.get("/users")
def list_users(pagination: dict = Depends(pagination_params)):
return {**pagination, "items": []}
@app.get("/orders")
def list_orders(pagination: dict = Depends(pagination_params)):
return {**pagination, "items": []}pagination_params. Both routes reuse it, and the page / size query params still show up in Swagger automatically.Depends(pagination_params), not Depends(pagination_params()). FastAPI calls it for you on each request.The Modern Annotated Style (Recommended)
The = Depends(...) form works, but the FastAPI-recommended way is Annotated. It lets you define the dependency type once and reuse it everywhere — cleaner signatures, no repetition.
from typing import Annotated
from fastapi import Depends, HTTPException, Query
def pagination_params(page: int = Query(1), size: int = Query(10)) -> dict[str, int]:
if page < 1 or size < 1 or size > 100:
raise HTTPException(status_code=400, detail="Bad pagination")
offset = (page - 1) * size
return {"page": page, "size": size, "offset": offset}
# Define the injectable type ONCE, reuse it in every route.
Pagination = Annotated[dict, Depends(pagination_params)]from fastapi import FastAPI
from app.dependencies import Pagination
app = FastAPI(title="FastAPI DI", version="0.1.0")
@app.get("/users")
def list_users(pagination: Pagination):
return {**pagination, "items": []}
@app.get("/orders")
def list_orders(pagination: Pagination):
return {**pagination, "items": []}| Style | Looks like | When to use |
|---|---|---|
= Depends() | param: dict = Depends(fn) | Fine, still supported everywhere |
Annotated | param: Annotated[dict, Depends(fn)] | Recommended — define once, reuse, cleaner |
Annotated from here on. It's the same style FastAPI uses for Session and current-user dependencies in real projects.Sub-Dependencies (Dependencies That Use Dependencies)
Dependencies can depend on other dependencies — FastAPI resolves the whole chain for you. This is how real auth works: read a token → decode it → load the user → check the role.
from typing import Annotated
from fastapi import Depends, Header, HTTPException
# Pretend token -> user mapping (dummy data for the demo).
FAKE_TOKENS = {
"token-admin": {"id": 1, "username": "thirdy", "role": "admin"},
"token-user": {"id": 2, "username": "maria", "role": "user"},
}
def get_token(authorization: str = Header(...)) -> str:
# Expect header: "Authorization: Bearer <token>"
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
return authorization.removeprefix("Bearer ")
def get_current_user(token: Annotated[str, Depends(get_token)]) -> dict:
user = FAKE_TOKENS.get(token)
if user is None:
raise HTTPException(status_code=401, detail="Invalid token")
return user
def require_admin(user: Annotated[dict, Depends(get_current_user)]) -> dict:
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return user
CurrentUser = Annotated[dict, Depends(get_current_user)]
AdminUser = Annotated[dict, Depends(require_admin)]from fastapi import FastAPI
from app.dependencies import AdminUser, CurrentUser
app = FastAPI(title="FastAPI DI", version="0.1.0")
@app.get("/me")
def me(user: CurrentUser):
return {"you_are": user["username"], "role": user["role"]}
@app.get("/admin/reports")
def admin_reports(admin: AdminUser):
return {"message": f"Welcome admin {admin['username']}", "reports": []}The chain for /admin/reports is: get_token → get_current_user → require_admin. You only wrote small, single-purpose functions and FastAPI stitched them together.
get_current_user appears in three places, the token is decoded only once.Yield Dependencies for Setup + Cleanup (DB Sessions)
The most important production pattern: a dependency that opens a resource, hands it to your endpoint, then always cleans up afterwards. You do this with yield instead of return.
class FakeSession:
"""Stand-in for a real SQLAlchemy/SQLModel session."""
def __init__(self) -> None:
self.closed = False
def query_users(self) -> list[dict]:
return [{"id": 1, "username": "thirdy"}, {"id": 2, "username": "maria"}]
def close(self) -> None:
self.closed = True
print("[db] session closed")from typing import Annotated
from collections.abc import Generator
from fastapi import Depends
from app.db import FakeSession
def get_session() -> Generator[FakeSession, None, None]:
session = FakeSession() # setup: runs before the endpoint
try:
yield session # this value is injected into the route
finally:
session.close() # teardown: runs AFTER the response is sent
SessionDep = Annotated[FakeSession, Depends(get_session)]from fastapi import FastAPI
from app.dependencies import SessionDep
app = FastAPI(title="FastAPI DI", version="0.1.0")
@app.get("/db/users")
def db_users(session: SessionDep):
return {"items": session.query_users()}Code before yield runs on the way in; code in the finally block runs on the way out — even if your endpoint raised an exception. That's why the session is guaranteed to close.
get_session yields a SQLModel/ SQLAlchemy Session, and every route that touches the DB just declares session: SessionDep. No manual open/close, no leaks.yield in try/finally. If you clean up withoutfinally, an exception in the endpoint skips your cleanup and leaks the connection.Class-Based Dependencies
When a dependency needs to group several related parameters (like filters), a class is cleaner than a function. A class is callable, so FastAPI treats Depends(MyClass) as "call the constructor with the request's query params."
from typing import Annotated
from fastapi import Depends
class UserFilters:
def __init__(self, q: str | None = None, role: str | None = None, active: bool = True) -> None:
self.q = q
self.role = role
self.active = active
Filters = Annotated[UserFilters, Depends(UserFilters)]from fastapi import FastAPI
from app.dependencies import Filters
app = FastAPI(title="FastAPI DI", version="0.1.0")
@app.get("/search")
def search_users(filters: Filters):
return {
"search": filters.q,
"role": filters.role,
"active_only": filters.active,
}filters.role) with editor autocomplete, and the constructor params become documented query parameters automatically.Router-Level and App-Level Dependencies
Sometimes a dependency must run on every route in a group — for example, requiring auth on an entire admin router. You attach it once with dependencies=[...] instead of adding it to each handler.
from fastapi import APIRouter, Depends, FastAPI
from app.dependencies import require_admin
app = FastAPI(title="FastAPI DI", version="0.1.0")
# Every route in this router requires an admin. No per-route repetition.
admin_router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
)
@admin_router.get("/stats")
def stats():
return {"users": 2, "orders": 5}
@admin_router.get("/settings")
def settings():
return {"maintenance": False}
app.include_router(admin_router)| Level | How | Use it for |
|---|---|---|
| Per-route | param: CurrentUser | When you need the returned value inside the handler |
| Router | APIRouter(dependencies=[...]) | A guard for a whole group (admin, internal APIs) |
| App | FastAPI(dependencies=[...]) | Something that must run on every request (rare) |
dependencies=[...] (in the decorator/router) when you only need the side effect — like "must be admin" — and don't need the return value. Use a parameter when you need the value.Try It in Swagger (/docs)
Install, run, and test. Every dependency's parameters show up in the docs automatically.
fastapi==0.116.1
uvicorn[standard]==0.30.6python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reloadOpen http://127.0.0.1:8000/docs. Call GET /me with header Authorization: Bearer token-user, then try GET /admin/reports with the same token to see the 403, then switch to token-admin.
# valid user
$ curl -H "Authorization: Bearer token-user" http://127.0.0.1:8000/me
{"you_are":"maria","role":"user"}
# non-admin hitting an admin route
$ curl -H "Authorization: Bearer token-user" http://127.0.0.1:8000/admin/reports
{"detail":"Admin only"}
# admin hitting the same route
$ curl -H "Authorization: Bearer token-admin" http://127.0.0.1:8000/admin/reports
{"message":"Welcome admin thirdy","reports":[]}
# yield dependency: watch the server log after the response
$ curl http://127.0.0.1:8000/db/users
{"items":[{"id":1,"username":"thirdy"},{"id":2,"username":"maria"}]}
# server console prints: [db] session closed403 for token-user and 200 for token-admin, and you see [db] session closed in the logs, your DI is working end to end.Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
Calling the dependency: Depends(fn()) | TypeError / dependency runs at import time | Pass the reference: Depends(fn) |
Using return where cleanup is needed | DB connections never close (leaks) | Use yield inside try/finally |
| Putting business logic in a route that another route also needs | Copy-paste drift and inconsistent validation | Extract it into a dependency |
Needing the value but using dependencies=[...] | Value not available in the handler | Declare it as a typed parameter instead |
| Expecting a dependency to run multiple times per request | Confused by cached results | Remember: same dependency is resolved once per request (use_cache) |
lru_cache for config) or make it async so it doesn't block the event loop.What's Next
You now understand the single most-used FastAPI feature. Here's where it pays off next:
fastapi-error-handling— turn thoseHTTPExceptions into clean, global error handlersfastapi-jwt— replace the fake token map with real access/refresh tokens (same DI pattern)fastapi-sqlmodel— swapFakeSessionfor a real database session viaget_sessionfastapi-pydantic-settings— inject typed config as a cached dependency
Recap: a dependency is any callable FastAPI runs for you and injects. Master Depends(), the Annotated style, sub-dependencies, and yield cleanup — and the rest of FastAPI (auth, DB, config) becomes just "another dependency."