Best Practices for FastAPI Dependency Injection

Key takeaways:

  • FastAPI has exactly one scope — per request. Application scope is something you build with lifespan and app.state.
  • Build expensive objects once at startup; let dependencies hand out handles, not construct pools.
  • Declare a dependency at the narrowest level that is still correct: router-level dependencies catch your health checks too.
  • A plain def provider runs in a threadpool, not on the event loop — verified, and a real concurrency ceiling.
  • Inject the narrowest useful object, because the handler's signature is also its permission list.

This guide turns the mechanics in Dependency Injection Strategies into decisions you make while writing code. It deliberately stays at the level of where things live and how long they live; the per-request cache, generator teardown, and test overrides each have their own guide, linked where they come up.

The Problem This Solves

Dependency injection in FastAPI is easy to start and easy to get subtly wrong, because the framework accepts every version of wrong without complaint. A dependency that builds a connection pool works perfectly in development and collapses under load. A dependency declared on the wrong router runs on the health check, so the load balancer starts failing whenever the auth service is slow. A synchronous provider that calls requests returns correct data and quietly caps your concurrency at the threadpool size.

None of these produce an error message. They produce a service that behaves differently in production than it did on your machine, which is why the habits below are worth adopting deliberately rather than discovering.

Why It Happens

The root of most of these mistakes is a mental model borrowed from other frameworks. Spring, .NET, and NestJS all offer a scope vocabulary — singleton, scoped, transient — and configure lifetime declaratively in a container. FastAPI has no container and no lifetime configuration. There is one rule:

A dependency callable is invoked while handling a request, and anything it returns lives as long as that request.

Everything else is a consequence. There is no singleton scope, so a dependency that returns SomeClient() returns a new client every request — the code looks like registration, but it is construction. There is no application scope either, so if you want one object shared across requests, you must create it somewhere that runs once. That place is the lifespan, and the bridge between it and the dependency system is app.state.

The second consequence concerns breadth rather than lifetime. FastAPI resolves the dependencies declared at three levels — application, router, and path operation — and it does not distinguish between them at resolution time. A dependency attached to a router is attached to every route the router owns, including the ones added later by someone who did not read the router definition.

Three lifetimes, only one of which FastAPI managesProcess scope holds settings created at import. Application scope holds the pool created in the lifespan and stored on app.state. Request scope holds the connection a dependency draws per request and closes after the response.Process scope — created at import, never rebuiltSettings() · lru_cache'd accessorsYou manage this. FastAPI is not involved.Application scope — created in lifespan, torn down at shutdownapp.state.pool · app.state.http_clientYou manage this too. Dependencies only read it.Request scope — the only lifetime FastAPI manages for youDepends(get_connection) · Depends(get_current_user)Resolved per request, released when the response is finished.Putting a pool in the bottom band is the single most expensive dependency mistake.

The Fix

1. Build once at startup, hand out per request

The pattern is two functions. The lifespan constructs; the dependency reads.

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # Application scope: built once, before the first request, torn down after the last.
    app.state.pool = ConnectionPool()
    yield
    app.state.pool = None


async def get_pool(request: Request) -> ConnectionPool:
    # A thin reader, not a factory. It hands out the one object the lifespan built.
    return request.app.state.pool


async def get_connection(
    pool: Annotated[ConnectionPool, Depends(get_pool)],
) -> AsyncIterator[Connection]:
    # Request scope: a fresh handle per request, drawn from the shared pool.
    conn = pool.connect()
    try:
        yield conn
    finally:
        conn.closed = True

Running that under a real lifespan and issuing four requests gives the recorded result:

$ GET /scopes
200 OK
{
  "requests": [
    {
      "request": "GET /private/orders",
      "status": 200,
      "response": {
        "connection_is_from_this_pool": true,
        "distinct_pools_so_far": 1,
        "distinct_connections_so_far": 1
      }
    },
    {
      "request": "GET /private/orders",
      "status": 200,
      "response": {
        "connection_is_from_this_pool": true,
        "distinct_pools_so_far": 1,
        "distinct_connections_so_far": 2
      }
    },
    {
      "request": "GET /private/health",
      "status": 200,
      "response": {
        "status": "ok"
      }
    },
    {
      "request": "GET /health",
      "status": 200,
      "response": {
        "status": "ok"
      }
    }
  ],
  "scope_check": {
    "requests_that_used_the_pool": 2,
    "distinct_pool_objects_handed_out": 1,
    "distinct_connection_objects_handed_out": 2,
    "handlers_got_the_object_lifespan_built": true
  },
  "router_dependency_fired_on": [
    "/private/orders",
    "/private/orders",
    "/private/health"
  ],
  "pool_after_shutdown": null
}

Two requests, one pool, two connections. handlers_got_the_object_lifespan_built is true, confirming the handlers received the same object the startup hook created rather than a fresh one. This is the shape you want, and it is worth asserting rather than assuming — the failure mode looks identical in development, where one pool per request costs nothing you would notice.

The distinction that matters is shared but immutable-ish versus stateful and per-caller. A connection pool, an HTTP client, a Redis client, a loaded ML model, and a settings object are all built once. A database session, a transaction, a cursor, and a per-request buffer must never be shared, because two concurrent requests holding one session will interleave their transactions. Getting this wrong in the other direction — a session at application scope — is the source of the "connection is closed" and "another operation is in progress" errors that dominate async database session debugging.

2. Declare dependencies at the narrowest level that works

Look again at router_dependency_fired_on in that transcript. The router was declared like this:

private = APIRouter(prefix="/private", dependencies=[Depends(audit_router_dependency)])

and the dependency fired on /private/orders twice and on /private/health. The health check inherited it, because it lives on the router. The /health route registered on the app directly does not appear in the list at all.

That is the whole hazard in one line of output. Put authentication on a router and every route on that router requires authentication — including the liveness probe your orchestrator calls every two seconds, which will now fail whenever your identity provider has a bad minute, and take the pods down with it. The rule that avoids it is structural rather than vigilant: keep unauthenticated routes on a different router. Health checks, metrics, and the OpenAPI document belong on a router with no dependencies, or on the app itself.

The same logic applies upward. An application-level dependency in FastAPI(dependencies=[...]) runs on literally everything, /docs included. It is the right place for something genuinely universal, and the wrong place for anything else.

3. Know which thread your provider runs on

FastAPI inspects whether a dependency is async def or def and treats them differently. This is easy to verify:

$ GET /where-providers-run
200 OK
{
  "handler_thread": "MainThread",
  "async_provider_ran_on": "MainThread",
  "sync_provider_ran_on": "AnyIO worker thread",
  "pool_was_built_on": "MainThread",
  "sync_provider_was_offloaded": true
}

The async def provider and the handler share MainThread — the event loop. The plain def provider was moved to AnyIO worker thread. This is FastAPI doing you a favour: a synchronous provider that blocks cannot stall the event loop, because it is not on it.

The favour has a limit. That threadpool has a fixed size (40 by default), and it is shared by every sync dependency, every sync path operation, and everything else offloaded to it. A sync provider that takes 200ms of network I/O does not block the loop, but it does occupy one of a few dozen slots, so throughput ceases to be governed by your async code at all. If a provider does I/O, make it async def and use an async client. Keep def for genuinely CPU-light, non-blocking work — reading a header, computing a hash — where the threadpool hop is pure overhead you are paying for nothing.

4. Inject the narrowest thing that does the job

A handler's parameters are the list of things it is able to do. Compare:

async def cancel_order(order_id: int, db: SessionDep, user: CurrentUser) -> Order: ...

async def cancel_order(order_id: int, orders: OrderRepoDep, user: CurrentUser) -> Order: ...

The first can execute arbitrary SQL against any table. The second can do exactly what OrderRepo exposes. That difference shows up in review, in the blast radius of a mistake, and in tests — faking an OrderRepo is four lines, whereas faking a session means faking a query interface. It is also what makes the protocol-based wiring from the circular import guide pay for itself twice.

Verification

Three tests capture the habits above, and each one fails loudly when someone regresses them:

def test_the_pool_is_not_rebuilt_per_request(client):
    # Guards against a pool constructed inside a dependency.
    first = client.get("/private/orders").json()
    second = client.get("/private/orders").json()
    assert first["distinct_pools_so_far"] == second["distinct_pools_so_far"] == 1


def test_health_checks_are_not_behind_auth(client):
    # No credentials supplied at all: the probe must still succeed.
    assert client.get("/health").status_code == 200


def test_no_blocking_provider_on_the_hot_path():
    # Every dependency on this router must be a coroutine function.
    for route in private.routes:
        for dep in route.dependant.dependencies:
            assert inspect.iscoroutinefunction(dep.call), f"{dep.call.__name__} is sync"

The second is worth adding even if you are sure. It costs one line and it is the test that catches the day someone moves a route onto the authenticated router for tidiness.

Trade-offs and When Not To

The app.state bridge is the weak point in all of this. It is untyped, so request.app.state.poool is an AttributeError on the first request rather than an error at startup, and no type checker will help you. Contain it: write one small reader dependency per resource, as get_pool does above, so exactly one line touches the untyped attribute and everything downstream is typed.

Narrow injection can also be taken too far. A repository per aggregate is good design; a dependency per query is ceremony that makes handlers harder to read than the SQL they replaced. Introduce the seam when you have a second implementation, a test that needs one, or a genuine coupling problem — not preemptively.

Finally, these habits are for services that will grow. A twelve-route internal tool with one database does not need protocols, layered scopes, or a router taxonomy, and applying all of it early produces a codebase whose structure is more complex than its behaviour. Start with Depends on the route, and adopt each habit when the corresponding failure becomes plausible.

FAQ

Does FastAPI have application-scoped or singleton dependencies? No. Every dependency FastAPI resolves is request-scoped at most. Application scope is something you build yourself by creating the object in the lifespan, storing it on app.state, and writing a dependency that reads it rather than constructs it. A verified run confirms two requests receive the same pool object and two different connection objects.

Should an expensive client be created in a dependency or in the lifespan? In the lifespan. A dependency runs per request, so constructing a connection pool or an HTTP client there rebuilds it on every call and destroys connection reuse. Build it once at startup, put it on app.state, and let the dependency hand out either the shared object or a per-request handle drawn from it.

Where should a dependency be declared: on the route, the router, or the app? At the narrowest level that is still correct. A dependency on a router runs for every route that router owns, including health checks, as a verified run shows. Declare authentication on the router that needs it, not on the application, and keep unauthenticated routes on a different router.

What happens if a dependency is a plain def instead of async def? FastAPI runs it in a threadpool rather than on the event loop. A verified run shows the sync provider executing on a thread named AnyIO worker thread while the async provider and the handler run on MainThread. That is safe for blocking work but the pool is finite, so a slow sync provider on a hot path becomes a concurrency ceiling.

Should dependencies return domain objects or raw handles? Return the narrowest thing the handler actually needs. A handler that receives a session and a user id can do anything to the database, whereas one that receives an OrderRepo is limited to orders. The narrower return type also makes the dependency trivially replaceable in tests.