Skip to content
Home Python FastAPI Dependency Injection — How and Why to Use It

FastAPI Dependency Injection — How and Why to Use It

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Python Libraries → Topic 40 of 51
Master FastAPI's dependency injection system with Depends().
⚙️ Intermediate — basic Python knowledge assumed
In this tutorial, you'll learn
Master FastAPI's dependency injection system with 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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()
🚨 START HERE
FastAPI DI Debug Quick Reference
Fast diagnostic commands for dependency injection issues — ordered by frequency in production
🟡Connection pool exhaustion — requests timing out on DB access, QueuePool limit errors in logs
Immediate ActionCheck active connection count and verify the yield dependency pattern is in place
Commands
SELECT count(*), state FROM pg_stat_activity WHERE datname = 'your_db' GROUP BY state;
grep -c 'QueuePool limit' /var/log/app.log
Fix NowConvert get_db to yield dependency. If active connections in pg_stat_activity exceed pool_size and keep growing under constant load, the leak is confirmed. The yield fix stops the growth immediately — connections will stabilize at the concurrent request count.
🟡Dependency not injecting expected value — endpoint receives None or wrong type despite dependency appearing to succeed
Immediate ActionInspect the dependency return type and the Annotated type annotation on the endpoint parameter
Commands
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()
Fix NowVerify the Annotated type on the endpoint parameter matches what the dependency yields. A type mismatch does not raise an error — FastAPI injects whatever the dependency returns, and if the type hint is wrong, mypy catches it but the runtime silently injects the wrong type.
🟡Test dependency override not taking effect — real services called, real database hit despite mock configuration
Immediate ActionVerify the override is configured before TestClient is instantiated and the correct function reference is used as the key
Commands
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()
Fix NowThe override key must be the exact function object imported from the module — not a re-import or a copy. If your app imports get_db from app.database and your test imports get_db from a different path, they may be different objects even if the code is identical. Use a single import source.
🟠Slow requests — p99 latency much higher than business logic execution time, dependency chain suspected
Immediate ActionCheck whether independent dependencies are nested (sequential execution) versus flat (parallel resolution with caching)
Commands
# 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 C
Fix NowFastAPI caches sub-dependency results per request by default. Ensure use_cache is not accidentally set to False on shared sub-dependencies. Flatten the graph so shared sub-dependencies are siblings, not duplicated descendants. Use OpenTelemetry spans inside yield dependencies to measure setup and teardown timing independently.
Production IncidentDatabase Connection Pool Exhaustion from Missing Yield in DependencyA FastAPI service leaked database connections on every failed request because the session dependency used a plain try/finally instead of yield, causing pool exhaustion and 503 errors under moderate load.
SymptomThe order service started returning 503 errors after roughly 20 minutes of traffic. Logs showed 'QueuePool limit of size 20 overflow 0 reached, connection timed out, timeout 30.0'. Restarting the pod cleared the symptom temporarily, but the errors returned within 20 minutes. Increasing pool_size to 50 bought more time before the same failure — which confirmed this was a leak, not an undersized pool.
AssumptionThe database was overloaded or the connection pool size was too small. The initial response was to increase pool_size and add a read replica. Neither fixed the issue because the root cause was not pool capacity — it was connection release.
Root causeThe get_db dependency was written as a regular function with manual try/except/finally, not as a yield dependency. When a route raised an HTTPException — the most common case being 422 validation errors on malformed request bodies — the exception propagated through FastAPI's exception handling layer before 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.
FixConverted get_db from a regular function to a yield dependency. FastAPI now holds the generator open across the request lifecycle and guarantees the finally block executes after the response is sent, regardless of whether the route succeeded or raised. Added a Prometheus metric tracking active connection count with an alert threshold at 80% of pool_size. Set pool_size=20, max_overflow=10, pool_recycle=300, pool_pre_ping=True to handle stale connections that survive across pool_recycle boundaries.
Key Lesson
Always use yield for resource-managing dependencies — plain try/finally does not survive FastAPI's exception handling interceptionFastAPI only guarantees teardown for yield dependencies — the generator resume is the mechanism, not the try/finally blockIncreasing pool_size to fix exhaustion without diagnosing the leak treats the symptom — the pool will always exhaust eventually if connections are not being releasedMonitor connection pool utilization with a dedicated metric — alert at 80% so you catch leaks before they cause user-visible failuresSet pool_pre_ping=True to detect stale connections at checkout rather than failing mid-request
Production Debug GuideCommon symptoms of dependency misconfiguration — with diagnosis steps, not just symptoms
Database connection pool exhausted — QueuePool limit reached errors under load, service recovers after pod restart but degrades again within minutesThe first thing to check is whether get_db (or any resource-acquiring dependency) uses yield. Without yield, route exceptions skip 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.
Dependency runs twice per request — side effects duplicated, counters incremented twice, unique IDs generated twiceTwo causes: either the same dependency is referenced in multiple places in the dependency graph without use_cache=False triggering re-execution (which would be a bug — the default is to cache), or use_cache=False was set intentionally but the dependency is shared across two sub-dependencies that both need independent executions. Audit the dependency graph by adding logging inside the function. If it is called twice when it should be cached, check for duplicate Depends() declarations at different levels.
Auth dependency validates successfully but the route still returns 401 or 403 — token is correct, user is authenticated, but access is deniedVerify the dependency is applied at the correct scope. Router-level dependencies compose with route-level ones — they do not override them. If your router applies a base auth check and the route applies an additional role check, both must pass. Also verify the dependency returns the identity object and not just a truthy value — if the route extracts a field from the returned object and that field is missing, you get an unexpected access failure that looks like an auth failure.
Dependency override not working in tests — real database is being hit, real auth service is being called, mock is ignoredOverrides must be set before the TestClient makes its first request. If you set app.dependency_overrides[get_db] = mock_db after TestClient(app) is already created, the override applies to subsequent requests — but if the client constructor triggers any startup events that call the dependency, those calls use the original. Set overrides before TestClient instantiation. After each test, call 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.

io/thecodeforge/dependencies/pagination.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
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}
Mental Model
Dependency as a Contract
A dependency declares a contract: 'I need these inputs to produce this output.' FastAPI fulfills the contract automatically — inspecting the signature, validating the inputs, and injecting the result. The endpoint never sees the raw request parameters, only the processed output.
  • 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
📊 Production Insight
Centralized pagination prevents the class of bug where one endpoint silently allows limit=100000 and hammers the database with an unbounded query.
Dependency parameters appear in OpenAPI schema automatically — consumers see a consistent, documented interface.
Rule: if two endpoints share identical parameter logic, extract it as a dependency. If three endpoints share it and one has a slightly different validation rule, that is a sign the rule should be standardized, not the sign to keep them separate.
🎯 Key Takeaway
Dependencies centralize shared parameter logic — one callable, many endpoints, one place to change. The dependency signature becomes part of the API contract and appears in OpenAPI documentation automatically. Extract when logic is shared across two or more endpoints; keep inline when it is genuinely unique. The goal is not maximum DI usage — it is eliminating duplication that creates drift.
When to Extract a Dependency
IfTwo or more endpoints share identical query parameter logic or validation rules
UseExtract to a dependency — single source of truth prevents drift and inconsistency across endpoints
IfParameter validation is complex — cross-field validation, range checks with business rules, conditional requirements
UseExtract to a dependency — centralize validation logic with clear, consistent error messages rather than duplicating conditionals in each route
IfParameter logic is simple and genuinely unique to one endpoint
UseKeep inline — do not over-abstract single-use parameters into a dependency that has only one caller
IfA shared parameter has different validation rules in different endpoints
UseStandardize the rule in the dependency — if the rules are legitimately different, question whether they should be different before creating two separate dependencies

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.

io/thecodeforge/security/auth.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
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}
⚠ Auth Dependency Pitfalls — and the Security Holes They Create
📊 Production Insight
One endpoint without the auth dependency is a security hole that survives for months. Code review catches logic errors; it does not reliably catch missing Depends() annotations when the endpoint looks structurally complete without it.
Router-level dependencies are structural enforcement — they make the secure path the only path for every route in the group.
Rule: apply auth at the router level. If you have a mix of public and protected routes, create separate routers. Mixing protected and unprotected routes in the same router always produces gaps eventually.
🎯 Key Takeaway
Auth dependencies gate the request — HTTPException in a dependency stops execution before the route runs, with no partial execution. Apply auth at the router level to make security structural rather than disciplinary. The dependency returns the identity object, not a boolean — the route needs to know who is calling to filter data, log the caller, and enforce data ownership boundaries.
Auth Dependency Placement Decision Tree
IfAll routes in a router require the same authentication check
UseApply Depends(validate_api_key) at the router level via APIRouter(dependencies=[...]). Every route in the group inherits the check structurally — no per-route annotation required.
IfSome routes are public and some require authentication in the same module
UseCreate two routers: a public router with no auth dependency and a protected router with the auth dependency. Register both on the app. Never mix public and protected routes in the same router.
IfDifferent routes need different auth levels — user, admin, service-to-service
UseUse sub-dependencies: require_admin depends on get_current_user, then checks role. FastAPI caches get_current_user, so the token is validated once regardless of how many role checks compose on top of it.
IfA route needs to access the identity AND enforce a role check
UseDeclare only the highest-level sub-dependency (require_admin). It returns the AuthenticatedUser after the role check — the route receives the identity without needing a separate Depends(get_current_user).

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.

io/thecodeforge/database/session.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
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 yieldnot 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.
⚠ Yield vs Plain Function — The Teardown Guarantee That Changes Everything
📊 Production Insight
A plain try/finally dependency leaks one connection per failed request. With 20 connections in the pool and one validation error per second, you get 20 seconds of operation before exhaustion. The math is unforgiving.
pool_pre_ping=True is the second most important pool configuration — without it, stale connections that survived across pool_recycle boundaries cause mid-request failures that look like application bugs but are infrastructure issues.
Rule: any dependency that acquires a resource uses yield. No exceptions to this rule. Code review should reject any resource-acquiring dependency that does not use yield.
🎯 Key Takeaway
yield dependencies are request-scoped context managers for resources. Only yield guarantees teardown on exceptions — plain try/finally does not survive FastAPI's exception handling boundary. Every resource-acquiring dependency must use yield. This is not a recommendation; it is a correctness requirement for production stability. A service that leaks connections under load will always fail — the only question is how long until it does.
Yield Dependency Decision Tree
IfDependency acquires a resource — database session, HTTP client, file handle, distributed lock, external connection
UseUse yield — teardown must be guaranteed even on exceptions. Add rollback() for transactional resources before close().
IfDependency is pure computation — parse and validate a JWT, extract query parameters, check a header format
UsePlain function is appropriate — there is no resource to release, and yield adds complexity without benefit.
IfDependency needs to run cleanup after the response is sent — emit a metric, write an audit log, record request duration
UseUse yield — code after yield runs after the response is sent. The cleanup happens without blocking the response to the client.
IfMultiple routes need isolated sessions — each request must have its own transaction boundary
UseUse yield dependency with per-request SessionLocal() creation. Never share a session across requests — sessions are not thread-safe and hold transaction state.
🗂 FastAPI Dependency Types: Complete Comparison
When to use each dependency pattern, what it guarantees, and what it costs
Dependency TypeTeardown GuaranteeUse CaseTrade-off
Plain function Depends()No — skipped on route exceptions due to FastAPI exception interceptionPure computation: parse token, validate params, extract query args, check header formatNo 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 sentResource management: DB sessions, HTTP client connections, file handles, distributed locksSlightly 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 factoriesMore 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 guaranteedComposed validation chains: Auth -> Role Check -> Permission Lookup -> DB SessionDeep nesting beyond 3 levels becomes hard to trace during debugging — flatten where possible
Router-level dependencyInherited from dependency type — applies to every route in the routerStructural enforcement: all routes get auth, rate limiting, request ID injection, loggingCannot 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 requestCross-cutting concerns: CORS preflight handling, global request ID injection, request timingRuns 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

    Using a plain function instead of yield for database session dependencies
    Symptom

    Connection pool exhaustion under moderate load — QueuePool limit reached errors, service recovers after pod restart but degrades again within minutes. The pool size looks adequate on paper but cannot handle the actual request rate because connections are never being released.

    Fix

    Convert get_db to a yield dependency. FastAPI resumes the generator after the response is sent or the exception is handled, which triggers the finally block. Add db.rollback() in an except clause before db.close() to prevent uncommitted transactions from surviving exceptions. Add pool utilization monitoring with an alert at 80% of pool_size — if the fix is working, the metric stabilizes at the concurrent request count instead of growing monotonically.

    Applying auth dependencies at the route level instead of the router level
    Symptom

    A new endpoint is added under a protected path prefix without the Depends(auth) annotation — it becomes publicly accessible. The gap is discovered in a security audit months later, after the endpoint has been in production. Because the other routes in the same file all have the annotation, the missing one looks like an intentional omission rather than a bug.

    Fix

    Apply authentication dependencies at the router level: APIRouter(prefix='/api/v1', dependencies=[Depends(validate_api_key)]). Every route registered on that router inherits the dependency structurally — there is no annotation to forget. Create separate routers for public and protected routes. Never mix public and protected routes in the same router, even if it feels like unnecessary structure early in the project.

    Not using use_cache=False for dependencies with side effects that must run independently each time they are referenced
    Symptom

    A dependency that generates a unique request correlation ID runs once per request as expected, but when two sub-dependencies both reference it, the ID is generated once and shared — which is correct for correlation IDs but wrong for dependencies that should produce independent values like idempotency keys or random nonces.

    Fix

    Set Depends(generate_nonce, use_cache=False) to force independent execution on each reference. By default, FastAPI caches dependency results for the entire request — this is the right behavior for expensive shared dependencies like DB sessions and wrong for dependencies that must produce unique values per reference. Document which dependencies use use_cache=False and why — it is non-obvious to future readers.

    Deep nesting of sub-dependencies creating an untraceable execution graph that is impossible to reason about during incident response
    Symptom

    A route with simple business logic has p99 latency significantly higher than the sum of its query times. Tracing shows time disappearing between route entry and first query. The dependency chain is 6 levels deep with repeated sub-dependency calls that are not being cached because of incorrect graph structure.

    Fix

    Flatten the dependency graph. If A depends on B and C, and both B and C depend on D, ensure D is a sibling in the graph (A depends on D, B depends on D, C depends on D) so FastAPI caches D once and injects it into both B and C. Use app.dependency_overrides in development to inject a tracing wrapper around each dependency. Add OpenTelemetry spans inside yield dependencies to measure setup and teardown timing independently from business logic.

    Dependency override not working in integration tests — real services called, real database hit, mock is ignored despite being configured
    Symptom

    Tests hit the real database despite app.dependency_overrides[get_db] = mock_db being configured. Some tests pass and some fail depending on test execution order, suggesting the override is sometimes active and sometimes not.

    Fix

    Set app.dependency_overrides[original_func] = mock_func before creating the TestClient — overrides are resolved at request time, not at app startup, but the TestClient must be created after the override is in place. Use the exact function object as the key — if your app imports get_db from app.database and your test imports it from a different path, they may be different objects. Clear overrides after each test with app.dependency_overrides.clear() to prevent state leakage between tests in the same session.

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
    FastAPI resolves the dependency graph as a directed acyclic graph (DAG) using topological ordering, with per-request caching as the key optimization. When Endpoint A declares Depends(B) and Depends(C), FastAPI builds the full dependency graph before executing anything. It identifies that D is a shared dependency referenced by both B and C. On the first resolution — let's say for B — FastAPI resolves D, executes it, and stores the result in a per-request cache keyed by the function object. When C needs D, FastAPI returns the cached result without re-executing D. This caching behavior is the default (use_cache=True on Depends). It ensures that expensive sub-dependencies — database sessions in particular — are created exactly once per request regardless of how many places in the dependency graph reference them. If D must run independently for each reference — for example, D generates a unique value or has a side effect that must not be shared — you declare it as Depends(D, use_cache=False). In that case, D executes once for B and once for C as separate calls. The practical implication: structure your dependency graph so shared sub-dependencies are siblings, not duplicated descendants. FastAPI handles the caching automatically, but only if the graph structure allows it to identify the shared reference.
  • QHow does FastAPI ensure that a 'yield' dependency cleans up resources if an unhandled exception occurs inside the route function?SeniorReveal
    FastAPI wraps yield dependency execution using Python's generator protocol and handles the teardown explicitly after the response is finalized. When a yield dependency is active, FastAPI pauses the generator at the yield statement, injects the yielded value into the route, and executes the route handler. After the handler completes — whether successfully or by raising an exception — FastAPI resumes the generator. If the handler raised an exception, FastAPI throws it into the generator (using generator.throw()), which causes the exception to propagate through the yield point and into the finally block. The finally block executes, the resource is cleaned up, and FastAPI then re-raises the exception to produce the error response. This is fundamentally different from a plain function dependency with try/finally. In a plain function, FastAPI calls the function to get the return value and never holds a reference to any cleanup code. When the route raises an HTTPException, FastAPI's exception handling converts it to a JSON response before returning to the caller — which means the dependency's finally block is never triggered because the dependency function already completed when it returned the value. The design implication is direct: yield is the only mechanism FastAPI provides for guaranteed resource teardown in dependencies. Any dependency that acquires a resource — database session, file handle, HTTP client, distributed lock — must use yield. There is no production-safe alternative.
  • 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
    The cleanest approach is a yield dependency that installs a SQLAlchemy event listener on the session for the duration of the request, then removes it during teardown. The dependency creates a session-level query filter using SQLAlchemy's @event.listens_for mechanism. Before yielding the session, it registers a 'before_compile' event that appends WHERE deleted_at IS NULL to every Query that does not explicitly opt out. After yield (in finally), it removes the listener to avoid affecting other sessions. Apply the dependency at the router level using APIRouter(prefix='/api/v1', dependencies=[Depends(soft_delete_session)]). Every route in that router automatically operates on a session that filters deleted records — no per-route annotation, no per-query filter condition. For admin routes that need access to deleted records — recovery endpoints, audit endpoints — create a separate router without the soft-delete dependency. This gives you a structural boundary between regular and admin access patterns rather than a per-route opt-out mechanism that someone will eventually forget to apply. One important detail: the event listener approach is more robust than passing a query builder parameter because it works even with ORM relationships and lazy-loaded associations, which would bypass a query-parameter-based filter.
  • QWhat are 'Security Dependencies' in FastAPI, and how do they integrate with the auto-generated Swagger (OpenAPI) documentation?JuniorReveal
    FastAPI provides built-in security utility classes — HTTPBearer, HTTPBasic, OAuth2PasswordBearer, APIKeyHeader — that are both dependency callables and OpenAPI schema generators. Using them does two things simultaneously: it wires the dependency into request processing, and it generates the corresponding security scheme in the OpenAPI specification. When you declare Depends(OAuth2PasswordBearer(tokenUrl='token')), FastAPI adds a 'securitySchemes' entry to the OpenAPI spec with type 'oauth2' and the token URL. Every endpoint that uses this dependency gets a 'security' field in its operation definition. The Swagger UI renders an 'Authorize' button that prompts for the token and sends it in the Authorization header on subsequent requests. Endpoints using the dependency show a lock icon indicating they require authentication. For API key authentication, APIKeyHeader(name='X-API-Key') documents the header in the schema and adds it to the endpoint's parameter list. Users can enter the key directly in the Swagger UI without inspecting HTTP headers manually. The integration means your authentication documentation is always synchronized with your implementation — there is no separate OpenAPI security configuration to maintain. When you change the dependency, the documentation updates automatically.
  • QHow would you override a dependency during an integration test to avoid hitting a real database?Mid-levelReveal
    Use app.dependency_overrides to map the original dependency function to a test-scoped replacement before the TestClient makes any requests. The pattern: ``python from fastapi.testclient import TestClient from app.main import app from app.database import get_db def override_get_db(): db = TestingSessionLocal() # In-memory SQLite or test schema try: yield db finally: db.close() # Set override BEFORE creating TestClient app.dependency_overrides[get_db] = override_get_db client = TestClient(app) # After test: app.dependency_overrides.clear() `` Three details that matter in production test suites: First, the key must be the exact function object — the same import used in the route declaration. If your app imports get_db from app.database and your test imports it differently, they may be different objects and the override will not match. Second, clear overrides after each test — use a pytest fixture with yield or an autouse teardown. Overrides that leak between tests cause intermittent failures that are nearly impossible to debug. Third, this pattern works for any dependency — not just database sessions. Override validate_api_key to return a mock AuthenticatedUser, override an external API client to return canned responses, override a feature flag dependency to force-enable a feature in tests. The override system is the primary mechanism for isolating FastAPI route tests from external dependencies.

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.

🔥
Naren Founder & Author

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.

← PreviousFastAPI Response Models and Status CodesNext →FastAPI Authentication — JWT and OAuth2 with Password Flow
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged