Dependency Caching and use_cache in FastAPI

Key takeaways:

  • Within one request, a dependency callable is resolved once and every later consumer gets the cached value — this is why fine-grained dependencies are cheap.
  • The cache key is the callable object plus its security scopes, compared by identity. Two functions that look identical are two cache entries.
  • Depends(fn, use_cache=False) forces re-evaluation at that declaration site only; it does not disable caching for the rest of the graph.
  • The cache is per request, not per process. For something genuinely global, wrap the provider in functools.lru_cache.
  • A dependency that "runs twice for no reason" is nearly always two distinct callable objects, not a caching bug.

This page drills into one specific behaviour of dependency injection strategies: what FastAPI remembers between two Depends() declarations in the same request, and what it deliberately does not.

The Problem This Solves

You add a get_settings dependency, then a get_current_user that depends on it, then a get_db that also depends on it. Three consumers, one provider. Does the provider run once or three times? If it opens a file, decodes a JWT, or hits Redis, the answer decides whether your dependency graph is free or is quietly tripling your per-request work.

The opposite failure is just as common: you deliberately want a dependency to run once per declaration — an audit hook, a rate-limit counter — and it silently collapses into a single call because FastAPI cached it.

Per-request dependency cache with one shared provider A handler depends on get_db and get_user; both depend on get_settings. get_settings executes once and the second consumer receives a cache hit. A third declaration marked use_cache equals False bypasses the cache and executes again. Route handler one request get_db get_user use_cache=False bypasses the cache get_settings executes once per request miss: runs hit: reused runs again The cache is discarded when the request ends.
Within one request a provider resolves once; later consumers get the stored value unless they opt out.

Why It Happens: the Per-Request Solved Cache

When a request arrives, FastAPI builds a dict of solved dependencies for that request and threads it through the whole resolution pass. Before it calls a provider, it constructs a cache key and checks that dict. The key is a two-part tuple:

(the dependency callable object, tuple(sorted(security_scopes)))

Two things follow from that shape, and between them they explain nearly every caching surprise people hit in production.

The callable is compared by identity, not by name or source. dependency_overrides works on exactly the same principle, which is why overriding dependencies in tests silently does nothing if you hand it a different-but-equivalent function. A closure returned twice from a factory produces two objects, so it produces two cache entries — even though __name__ is identical for both.

Security scopes are part of the key. The same get_current_user requested with Security(get_current_user, scopes=["read"]) and with scopes=["write"] resolves twice, because the scopes it is being asked for differ and the answer legitimately might too.

The cache dictionary is created per request and thrown away with it. There is no cross-request memoization anywhere in the DI machinery.

Proving It: Real Call Counts

The only convincing way to settle "does it run once or three times" is to count. This app increments a module-level Counter inside each provider, clears it at the start of every request in middleware, and returns the counts in the response body.

"""use_cache semantics: when a dependency is evaluated once vs repeatedly in one request."""
from collections import Counter
from typing import Annotated

from fastapi import Depends, FastAPI, Request

app = FastAPI()

CALLS: Counter = Counter()


@app.middleware("http")
async def reset_counters(request: Request, call_next):
    CALLS.clear()                      # Each transcript line starts from zero.
    return await call_next(request)


async def get_settings() -> dict:
    CALLS["get_settings"] += 1
    return {"env": "prod"}


async def get_db(settings: Annotated[dict, Depends(get_settings)]) -> str:
    CALLS["get_db"] += 1
    return "session"


async def get_user(settings: Annotated[dict, Depends(get_settings)]) -> str:
    CALLS["get_user"] += 1
    return "alice"


async def get_settings_uncached(
    settings: Annotated[dict, Depends(get_settings, use_cache=False)],
) -> dict:
    return settings


def make_rate_limiter():
    """Two calls to this factory produce two distinct callables with the same __name__."""

    async def rate_limit() -> bool:
        CALLS["rate_limit"] += 1
        return True

    return rate_limit


limiter_a = make_rate_limiter()
limiter_b = make_rate_limiter()


@app.get("/cached")
async def cached(
    db: Annotated[str, Depends(get_db)],
    user: Annotated[str, Depends(get_user)],
    settings: Annotated[dict, Depends(get_settings)],
):
    return {"calls": dict(CALLS)}


@app.get("/uncached")
async def uncached(
    db: Annotated[str, Depends(get_db)],
    user: Annotated[str, Depends(get_user)],
    fresh: Annotated[dict, Depends(get_settings_uncached)],
    also_fresh: Annotated[dict, Depends(get_settings, use_cache=False)],
):
    return {"calls": dict(CALLS)}


@app.get("/two-limiters", dependencies=[Depends(limiter_a), Depends(limiter_b)])
async def two_limiters():
    return {
        "same_name": limiter_a.__name__ == limiter_b.__name__,
        "same_object": limiter_a is limiter_b,
        "calls": dict(CALLS),
    }


@app.get("/one-limiter", dependencies=[Depends(limiter_a), Depends(limiter_a)])
async def one_limiter():
    return {"calls": dict(CALLS)}

Running that app under FastAPI 0.139.2 produces this transcript — it is the tool's real output, not a description of it:

$ GET /cached
200 OK
{
  "calls": {
    "get_settings": 1,
    "get_db": 1,
    "get_user": 1
  }
}

$ GET /uncached
200 OK
{
  "calls": {
    "get_settings": 3,
    "get_db": 1,
    "get_user": 1
  }
}

$ GET /two-limiters
200 OK
{
  "same_name": true,
  "same_object": false,
  "calls": {
    "rate_limit": 2
  }
}

$ GET /one-limiter
200 OK
{
  "calls": {
    "rate_limit": 1
  }
}

Read the four lines carefully, because each one settles a separate argument.

GET /cached — three consumers of get_settings (get_db, get_user, and the handler itself), one execution. Caching is on by default and it works exactly as advertised.

GET /uncachedget_settings runs three times. One of those is the ordinary cached resolution feeding get_db and get_user; the other two come from the two declarations that passed use_cache=False. Note that get_db and get_user still ran once each: opting out at one declaration site does not poison the cache for the rest of the graph.

GET /two-limiters — this is the important one. limiter_a and limiter_b came out of the same factory. Their __name__ is identical (same_name: true) and their bodies are the same source code, but they are different objects (same_object: false), so the counter reaches 2. FastAPI is not comparing functions structurally; it is comparing them with is inside a dict lookup.

GET /one-limiter — declaring the same object twice on the same route collapses to one call, confirming the previous result was about object identity and nothing else.

Verification in Your Own App

You do not need a counter in production code. Two cheap checks:

def test_settings_resolved_once(client, monkeypatch):
    calls = []
    monkeypatch.setattr(app_module, "_load_settings", lambda: calls.append(1) or SETTINGS)
    client.get("/orders/1")
    assert len(calls) == 1        # fails loudly if a second cache key appears

And at runtime, log the provider's entry at DEBUG with the request ID from your request-tracing middleware. Two lines with the same request ID means two cache keys, and the fix is upstream in how the dependency is referenced — not in the caching flag.

Request Cache vs Process Cache

The per-request cache is the wrong tool for something like parsed settings, which do not change between requests at all. The idiomatic pairing is both layers at once:

from functools import lru_cache

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str
    jwt_secret: str


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    # lru_cache: once per process. FastAPI's DI cache: once per request.
    return Settings()

lru_cache makes the parse happen once for the lifetime of the worker; FastAPI's cache then makes the lookup happen once per request. This is the standard wiring described in managing environment variables with pydantic-settings, and it is worth understanding that the two caches are entirely independent mechanisms that happen to compose well.

For results that should outlive a request but must expire — a permissions lookup, a feature flag set — neither layer is right, and you want the deliberate approach in caching dependency results.

Trade-Offs and When Not To

Do not reach for use_cache=False to fix a staleness bug. If a value changes mid-request, the more honest model is usually that the dependency should return a handle (a session, a client, a repository) rather than a snapshot, and the caller asks it for fresh data. Turning caching off converts one cheap call into N calls at every declaration site, and N grows as the app does.

Yield dependencies interact with the cache in a way that matters. A cached yield dependency has exactly one setup and one teardown. Setting use_cache=False on a session provider gives you two independent sessions in one request, with two teardowns, which is almost never what anyone wants — see yield dependencies and cleanup order for what that teardown sequence actually looks like.

Caching hides accidental cost, it does not remove it. A provider doing a database round trip is cheap relative to being called five times, but it is still a round trip on every request. Caching makes the graph tidy; it does not make an expensive dependency free.

Router-level and handler-level declarations of the same callable share the cache. Attaching Depends(verify_api_key) to an APIRouter and also naming it in one handler's signature does not double-execute it, provided both refer to the same imported object. That is a feature: gates declared for a whole group of routes in modular router organization stay idempotent.

FAQ

Is the FastAPI dependency cache shared between requests? No. The cache lives in the per-request solved-dependency dictionary and is discarded when the request finishes. A dependency shared by five consumers runs once per request, not once per process. For process-wide caching you need functools.lru_cache on the provider itself.

What exactly is the cache key? It is a tuple of the dependency callable object and the sorted security scopes for that declaration. The callable is compared by identity, so two functions with the same name, same source and same behaviour are still two distinct keys.

When should I actually set use_cache=False? When the dependency must produce a fresh value each time it is asked for within one request, such as a per-call timestamp, a fresh nonce, or a counter you want incremented once per declaration site. It is rarely the right answer for I/O-backed providers.

Why does my dependency run twice even though caching is on? Almost always because the two declarations do not reference the same callable object. A dependency declared on the router and re-declared in the handler using a different alias, a re-imported module, or a factory-produced closure creates two cache keys.

Does use_cache=False re-run sub-dependencies too? It re-runs the dependency you marked, and that dependency's own sub-dependencies are resolved through the same request cache. Sub-dependencies that are already cached are reused, so only the uncached node is recomputed. The /uncached transcript above shows exactly that: get_settings climbs to three while get_db and get_user stay at one.