FastAPI Dependency Injection — How and Why to Use It
Depends().Depends()is the primary tool for Inversion of Control in FastAPI — it decouples infrastructure concerns (auth, sessions, config) from business logic in route handlers, making both independently testable and independently changeable.- Dependency caching: FastAPI reuses the first result of each dependency within a single request by default. Shared sub-dependencies like database sessions are resolved once regardless of how many places in the dependency graph reference them — use use_cache=False only when independent execution per reference is intentionally required.
- The yield syntax provides guaranteed setup and teardown for request-scoped resources — code before yield runs during setup, code in the finally block runs after the response is sent or the exception is handled. This guarantee does not exist for plain function dependencies, making yield mandatory for any dependency that acquires a resource.
- FastAPI Depends() declares functions that run before your route logic — framework handles resolution and injection
- Dependencies enable Inversion of Control: you declare what an endpoint needs, FastAPI handles the how
- yield dependencies act as context managers — setup before yield, teardown in finally, even on exceptions
- Sub-dependencies nest recursively: Auth -> Role Check -> DB Lookup creates a validation graph
- By default FastAPI caches dependency results per request — set use_cache=False for side-effect dependencies
- Biggest mistake: copy-pasting auth checks into every route instead of centralizing with Depends()
Connection pool exhaustion — requests timing out on DB access, QueuePool limit errors in logs
SELECT count(*), state FROM pg_stat_activity WHERE datname = 'your_db' GROUP BY state;grep -c 'QueuePool limit' /var/log/app.logDependency not injecting expected value — endpoint receives None or wrong type despite dependency appearing to succeed
print(app.routes) # Verify route dependency declarations are present# Add explicit logging inside the dependency to confirm execution and return value:
def get_db():
logger.info('get_db called — creating session')
db = SessionLocal()
try:
logger.info(f'get_db yielding session id={id(db)}')
yield db
finally:
logger.info(f'get_db closing session id={id(db)}')
db.close()Test dependency override not taking effect — real services called, real database hit despite mock configuration
app.dependency_overrides[get_db] = lambda: mock_session # Must be the exact same function object# Clear after each test to prevent state leakage:
def teardown():
app.dependency_overrides.clear()Slow requests — p99 latency much higher than business logic execution time, dependency chain suspected
# Anti-pattern: D executes twice because B and C both declare Depends(D) with use_cache=False
# A depends on B (which depends on D), A depends on C (which depends on D)
# D runs once for B, once for C — sequential, not cached# Correct pattern: D executes once, result cached and shared
# A depends on B, A depends on C, both B and C depend on D
# FastAPI resolves D first, caches result, injects into both B and CProduction Incident
db.close() in the dependency's finally block could execute.
FastAPI intercepts HTTPException at the route execution level and converts it to a JSON response. This interception happens before control returns to the dependency's finally block in a plain function. Only yield dependencies get a guaranteed callback after the route completes or raises — because FastAPI holds a reference to the generator and explicitly resumes it after the response is handled.
With a pool of 20 connections and roughly one validation error per second, the pool exhausted in approximately 20 seconds. Each failed request left one connection open, held by a session object that was never closed. The connection eventually timed out and was returned to the pool — but by then, the queue of waiting requests had already triggered a cascade of 503s.Production Debug GuideCommon symptoms of dependency misconfiguration — with diagnosis steps, not just symptoms
db.close() because FastAPI's exception handling intercepts before the dependency's finally block runs. Convert to yield dependency immediately. Then add a connection count metric to verify the fix — if active connections stabilize below pool_size after the change, the diagnosis was correct.Depends() declarations at different levels.app.dependency_overrides.clear() to prevent state from leaking into the next test.Basic Dependency — Shared Query Parameters
A dependency is just a standard Python callable — a function, a class, or anything with a __call__ method. By wrapping it in Depends(), you tell FastAPI to treat that callable's parameters as if they were declared directly on the endpoint. FastAPI introspects the signature, resolves the parameters from the incoming request (query string, headers, body), and injects the return value into your route handler.
This is the cleanest way to standardize pagination, filtering, sorting, or any shared input-processing logic across your entire API. The key insight is that the dependency function signature becomes part of the API contract — query parameters declared inside a dependency appear in the auto-generated OpenAPI schema exactly as if they were declared on the endpoint itself. API consumers see a consistent interface. Your codebase has one source of truth.
In production, this pattern eliminates an entire class of bugs: parameter drift across endpoints. Without DI, you copy the pagination logic into every route. Six months later, one endpoint caps limit at 100 and another allows limit=10000. One endpoint validates that skip is non-negative and another silently accepts skip=-5. The dependency pattern makes these inconsistencies structurally impossible — there is one function, one validation path, one default value. Changing it changes every endpoint that uses it simultaneously.
from fastapi import FastAPI, Depends, Query, HTTPException, status from typing import Annotated app = FastAPI() def common_params( skip: int = Query(default=0, ge=0, description="Number of records to skip"), limit: int = Query(default=100, ge=1, le=1000, description="Maximum records to return"), ) -> dict: """ Standardized pagination parameters for all list endpoints. Enforces a hard cap at 1000 to prevent unbounded queries. ge=0 on skip prevents negative offsets that some ORMs handle unpredictably. This function is the single source of truth for pagination logic. Changing the cap or default here applies to every endpoint that uses it — no find-and-replace required. """ return {"skip": skip, "limit": limit} # Type alias for readability — used across multiple route files PaginationParams = Annotated[dict, Depends(common_params)] @app.get("/users") def list_users(params: PaginationParams): """ List users with standardized pagination. Query params 'skip' and 'limit' appear in OpenAPI docs even though they are declared inside common_params, not here. """ # Business logic: fetch users using params["skip"] and params["limit"] return {"context": "users", "pagination": params} @app.get("/orders") def list_orders(params: PaginationParams): """ List orders with the same pagination contract. If the cap changes from 1000 to 500, it changes here and applies to both /users and /orders automatically. """ return {"context": "orders", "pagination": params} @app.get("/products") def list_products(params: PaginationParams, category: str | None = None): """ Dependencies compose cleanly with route-level parameters. category is unique to this endpoint; skip and limit come from the dependency. Both appear in the OpenAPI schema. """ return {"context": "products", "category": category, "pagination": params}
- The dependency function signature IS the contract — its parameters become API inputs visible in OpenAPI docs
- FastAPI validates dependency parameters with the same rules as route parameters — Query constraints, type coercion, required vs optional
- The return value is injected into the endpoint as a typed argument — use Annotated for explicit type documentation
- Multiple endpoints share one dependency — single source of truth for validation logic, defaults, and constraints
- Changing the dependency changes every endpoint that uses it simultaneously — no copy-paste drift possible
Authentication Dependency & Logic Branching
Dependencies are the gatekeepers of your routes. By raising an HTTPException inside a dependency, you stop the request before it reaches your route handler. The route never executes, the business logic never runs, and the response is returned immediately with the error status code. This is not just convenient — it is a security property.
The authentication dependency pattern separates the concern of 'who is this request from' from 'what does this request do.' The endpoint never needs to know how authentication works. It does not parse tokens, query user tables, or check API key formats. It receives an authenticated identity object and operates on it. The auth mechanism is entirely contained in the dependency.
This separation has a direct and concrete security benefit: changing your auth mechanism requires changing one function. JWT to OAuth2, API keys to mutual TLS, single-tenant to multi-tenant — the endpoint code is untouched. Without DI, you would need to audit every route handler individually, and that audit will miss something. There is no version of 'update every route manually' that is reliably complete.
The second security benefit is placement. Applied at the router level, auth dependencies create a structural guarantee: every route in the group requires authentication. There is no way to add a new endpoint and forget the auth check — the router applies it automatically. Applied at the route level, auth is a per-developer discipline that fails the moment someone is moving fast and forgets.
from fastapi import FastAPI, Depends, HTTPException, Header, status, APIRouter from typing import Annotated from dataclasses import dataclass @dataclass class AuthenticatedUser: """ The identity object returned by a successful auth dependency. Returning a structured object instead of a raw dict makes downstream role checks type-safe and self-documenting. Mypy catches accesses to non-existent fields at development time rather than at runtime in production. """ user_id: str role: str scopes: list[str] def validate_api_key( x_forge_token: Annotated[str | None, Header()] = None, ) -> AuthenticatedUser: """ Validate the X-Forge-Token header against the allowed key set. In production, replace the hardcoded check with a lookup against HashiCorp Vault, AWS Secrets Manager, or a secrets database. Never hardcode secrets in source — this example uses a literal only to show the dependency structure. Raises HTTPException 401 if the token is missing or invalid. Returns an AuthenticatedUser if valid — the route receives the identity, not a boolean. """ if not x_forge_token or x_forge_token != "forge-prod-secret": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing Forge API Key", headers={"WWW-Authenticate": "ApiKey"}, ) # In production: look up the key, fetch the associated user and scopes return AuthenticatedUser( user_id="svc-order-service", role="service", scopes=["orders:read", "orders:write"], ) def require_admin( auth: Annotated[AuthenticatedUser, Depends(validate_api_key)], ) -> AuthenticatedUser: """ Sub-dependency for admin-only endpoints. Composes on top of validate_api_key — the request must first pass the base auth check, then pass the role check. FastAPI caches the validate_api_key result, so the token is only validated once even though two dependencies reference it. """ if auth.role != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Admin role required. Caller role: {auth.role}", ) return auth # Protected router — every route inherits auth at the router level # No individual route can skip auth by forgetting the Depends() annotation protected_router = APIRouter( prefix="/api/v1", dependencies=[Depends(validate_api_key)], ) # Admin router — sub-dependency composes on the base auth check admin_router = APIRouter( prefix="/admin", dependencies=[Depends(require_admin)], ) @protected_router.get("/orders") def list_orders(auth: Annotated[AuthenticatedUser, Depends(validate_api_key)]): """ The route receives the identity object directly. It knows WHO is calling, not just that auth passed. This enables per-user data filtering without a second DB lookup. """ return { "caller": auth.user_id, "scopes": auth.scopes, "orders": [], # Fetch filtered by auth.user_id in production } @admin_router.get("/metrics") def get_internal_metrics(auth: Annotated[AuthenticatedUser, Depends(require_admin)]): """ Only admin-role callers reach this handler. require_admin handles both auth and role — the route is clean. """ return {"metrics": "[REDACTED]", "caller": auth.user_id}
Depends() annotations when the endpoint looks structurally complete without it.The 'Yield' Pattern: Database Session Management
Managing database connections is where FastAPI's dependency injection either saves you or burns you. You must open the session before the route runs, and you must close it — return it to the pool — after the route completes, regardless of whether the route succeeded, raised an HTTPException, or crashed with an unhandled error. 'Regardless of outcome' is the hard part.
FastAPI's yield dependencies solve this by acting as request-scoped context managers. Code before the yield statement runs during setup — opening the session, acquiring any necessary locks, initializing state. Code after the yield in a finally block runs during teardown — closing the session, releasing locks, cleaning up. FastAPI holds a reference to the generator across the entire request lifecycle and resumes it after the response is sent or the exception is handled.
The critical distinction — the one that causes the most production incidents — is between yield dependencies and plain function dependencies with try/finally blocks. In a plain function dependency, the finally block does not execute when the route raises an HTTPException. FastAPI intercepts the exception at the route execution boundary and converts it to a JSON error response. This interception happens before control returns to the plain function's finally block. The database session is never closed. The connection leaks.
Only yield dependencies get guaranteed teardown because FastAPI explicitly resumes the generator after exception handling. The generator is paused at the yield, the exception is handled, the response is sent, and then FastAPI calls next() on the generator to trigger the finally block. This is not automatic — it is a deliberate design in FastAPI's dependency execution model. If you are acquiring any resource in a dependency — database session, HTTP client, file handle, distributed lock — you must use yield. There is no production-safe alternative.
from fastapi import FastAPI, Depends, HTTPException, status from sqlalchemy import create_engine, event, text from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.pool import QueuePool from typing import Annotated, Generator import logging logger = logging.getLogger(__name__) SQLALCHEMY_DATABASE_URL = "postgresql://user:password@localhost/forge_db" engine = create_engine( SQLALCHEMY_DATABASE_URL, poolclass=QueuePool, pool_size=20, # Base connections kept open max_overflow=10, # Additional connections allowed under burst load pool_recycle=300, # Recycle connections after 5 minutes to prevent stale state pool_pre_ping=True, # Test connection health at checkout — catches dropped connections pool_timeout=30, # Raise after 30s waiting for a connection — fail fast, not hang ) SessionLocal = sessionmaker( autocommit=False, autoflush=False, bind=engine, ) def get_db() -> Generator[Session, None, None]: """ Yield a database session for the duration of a single request. MUST use yield — not return. With yield: FastAPI holds the generator open across the request lifecycle. After the route completes (or raises), FastAPI resumes the generator, triggering the finally block that closes the session. Without yield (plain try/finally): FastAPI's exception handling intercepts HTTPException before the finally block executes. The session is never closed. The connection leaks. The pool exhausts. The service returns 503. This is the most common FastAPI production bug — and the most preventable one. """ db = SessionLocal() logger.debug(f"DB session opened: id={id(db)}") try: # Setup phase: session is open and ready yield db # If the route commits explicitly, nothing to do here # If the route did not commit, nothing is committed — no surprise writes except Exception: # Roll back any uncommitted transaction before closing # Prevents partial writes from surviving an exception db.rollback() raise finally: # Teardown phase: ALWAYS runs — success, HTTPException, or crash logger.debug(f"DB session closed: id={id(db)}") db.close() # Type alias — use this in route signatures for readability DBSession = Annotated[Session, Depends(get_db)] app = FastAPI() @app.get("/db-status") def check_db_connection(db: DBSession): """ Health check that validates the DB session is functional. Uses db.execute() to confirm the connection is active, not just that the session object was created. """ try: db.execute(text("SELECT 1")) return {"status": "connected", "session_id": id(db)} except Exception as e: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Database unreachable: {str(e)}", ) @app.get("/orders/{order_id}") def get_order(order_id: int, db: DBSession): """ Route that uses the injected session for a real query. Whether this raises 404, 422, or succeeds — the session is closed. FastAPI guarantees teardown via the yield generator resume. """ # order = db.query(Order).filter(Order.id == order_id).first() # if not order: # raise HTTPException(status_code=404, detail="Order not found") # return order return {"order_id": order_id, "status": "fetched", "session_id": id(db)} # Example of what NOT to do — shown explicitly so the bug is recognizable in review: # def get_db_wrong(): # db = SessionLocal() # try: # return db # Returns immediately — FastAPI has no generator to resume # except Exception: # db.close() # raise # finally: # db.close() # This finally block DOES NOT RUN when the route raises HTTPException # # FastAPI intercepts the exception before returning here # # Connection leaks. Pool exhausts. 503s at moderate load.
rollback() for transactional resources before close().SessionLocal() creation. Never share a session across requests — sessions are not thread-safe and hold transaction state.| Dependency Type | Teardown Guarantee | Use Case | Trade-off |
|---|---|---|---|
Plain function Depends() | No — skipped on route exceptions due to FastAPI exception interception | Pure computation: parse token, validate params, extract query args, check header format | No cleanup mechanism — do not use for any resource acquisition, no matter how minor |
Yield Depends() | Yes — finally block runs even on unhandled exceptions, after response is sent | Resource management: DB sessions, HTTP client connections, file handles, distributed locks | Slightly more complex lifecycle — must understand setup/teardown phases and where exceptions fall |
Class-based Depends() | No (unless __call__ uses yield internally) | Stateful logic: service objects, complex configuration, multi-method validators, dependency factories | More verbose and requires understanding of Python callable protocol — worth it for complex reusable services |
| Sub-dependency (nested Depends) | Inherited from the inner dependency type — yield inside sub-dependency is guaranteed | Composed validation chains: Auth -> Role Check -> Permission Lookup -> DB Session | Deep nesting beyond 3 levels becomes hard to trace during debugging — flatten where possible |
| Router-level dependency | Inherited from dependency type — applies to every route in the router | Structural enforcement: all routes get auth, rate limiting, request ID injection, logging | Cannot exempt individual routes without creating a separate router — plan router boundaries during design |
| Global dependency (app level) | Inherited from dependency type — runs on every single request | Cross-cutting concerns: CORS preflight handling, global request ID injection, request timing | Runs on every request including health checks and static file serving — add path exclusions for non-API routes |
🎯 Key Takeaways
Depends()is the primary tool for Inversion of Control in FastAPI — it decouples infrastructure concerns (auth, sessions, config) from business logic in route handlers, making both independently testable and independently changeable.- Dependency caching: FastAPI reuses the first result of each dependency within a single request by default. Shared sub-dependencies like database sessions are resolved once regardless of how many places in the dependency graph reference them — use use_cache=False only when independent execution per reference is intentionally required.
- The yield syntax provides guaranteed setup and teardown for request-scoped resources — code before yield runs during setup, code in the finally block runs after the response is sent or the exception is handled. This guarantee does not exist for plain function dependencies, making yield mandatory for any dependency that acquires a resource.
- Sub-dependencies compose cleanly: Auth -> Role Check -> DB Lookup creates a validation graph where each layer can be tested independently and changed without touching the others. FastAPI handles topological ordering and caching automatically.
- Router-level dependencies create structural enforcement — every route in the group inherits the dependency without per-route annotation. Apply auth and cross-cutting concerns at the router level; per-route annotation is a maintenance discipline that fails under deadline pressure.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QDescribe the 'Dependency Graph' resolution in FastAPI. How does it handle a scenario where Endpoint A depends on B and C, while both B and C depend on D?Mid-levelReveal
- QHow does FastAPI ensure that a 'yield' dependency cleans up resources if an unhandled exception occurs inside the route function?SeniorReveal
- QScenario: You need to implement a 'Soft-Delete' filter globally. How would you use a Router-level dependency to ensure every query in a specific module excludes deleted records?SeniorReveal
- QWhat are 'Security Dependencies' in FastAPI, and how do they integrate with the auto-generated Swagger (OpenAPI) documentation?JuniorReveal
- QHow would you override a dependency during an integration test to avoid hitting a real database?Mid-levelReveal
Frequently Asked Questions
What is the difference between Depends() and a regular function call?
A regular function call requires you to provide all arguments manually at the call site. With Depends(), FastAPI takes over: it inspects the dependency's parameter signature, resolves each parameter from the incoming request (query string, headers, path, body), recursively resolves any nested Depends() declarations within the function, caches the result for the request duration, and injects the return value into your route handler.
The key capability that Depends() enables and regular function calls cannot replicate is recursive dependency resolution with per-request caching. If your auth dependency needs a database session, and the database session dependency needs the engine configuration, FastAPI resolves the entire graph automatically in the correct order. A regular call chain requires you to manually thread arguments through every level — which defeats the purpose of the abstraction.
When should I use use_cache=False in a dependency?
Use use_cache=False when the dependency has side effects that must execute independently each time it is referenced in a single request — not when it should simply be recomputed.
The concrete cases: generating unique idempotency keys (each reference should produce a different value), incrementing a per-reference counter, or acquiring separate independent resource instances rather than sharing one.
Do not use use_cache=False for performance reasons or to force freshness — the cache is per-request, not across requests, so every request always gets a fresh execution. use_cache=False only affects whether multiple references within the same request share a result or each get their own execution.
Document use_cache=False explicitly at the declaration site — it is non-obvious to future readers and easy to misinterpret as a bug.
Can I use dependencies for Class-based logic?
Yes, and it is particularly useful for complex stateful services or dependency factories where you want method reuse, initialization logic, or configurable behavior.
The pattern: define a class with __init__ to accept parameters (which FastAPI resolves from the request) and __call__ to implement the dependency logic. FastAPI calls the class as a callable, resolving __init__ parameters the same way it resolves function parameters.
For example, a permission checker class that takes the required permission string in __init__ and validates it against the current user in __call__ creates a reusable, configurable dependency: Depends(RequirePermission('orders:write')). Each instance is configured differently but implements the same interface.
Class-based dependencies are more verbose than function dependencies — use them when the added structure pays for itself through reuse, testability, or configuration flexibility. Do not use them as a default pattern.
How do I debug which dependencies are being called and in what order?
FastAPI does not provide a built-in dependency execution trace. You need to instrument manually.
For development debugging: add logger.info() at the entry and exit of each dependency function. The log output will show the execution order clearly, including which dependencies are cached (they appear once) and which execute multiple times (use_cache=False or a bug in graph structure).
For test-time tracing: use app.dependency_overrides to inject a wrapper function that logs the call and delegates to the original. This lets you trace execution order without modifying production code.
For production observability: add OpenTelemetry spans inside yield dependencies. Wrap the setup phase in one span and the teardown in another — this gives you timing data for both phases in your distributed trace, separated from the route handler's business logic span. This is particularly useful for diagnosing where latency is accumulating in deep dependency chains.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.