Caching Dependency Results in FastAPI

Key takeaways:

  • FastAPI already caches a dependency per request; a value used by five sub-dependencies is computed once.
  • use_cache=False is per declaration site — you must set it everywhere in the graph to actually force re-execution.
  • Process-scoped caches survive across requests but are per worker, so invalidation is only ever local.
  • A cache key that omits the user is a data leak: the first caller's permissions get served to everyone.
  • functools.lru_cache on an async def dependency caches the coroutine, not the result, and breaks on the second await.

A permissions lookup shows up three times in one request's dependency graph and you want to know whether that is three database round trips. Then you want to know whether you can keep the answer between requests, and what it costs you when you do. This page is part of Caching Strategies, and it deals with the dependency layer specifically — the cache that sits closest to your business logic and is the easiest one to get subtly wrong.

Three cache scopes for a dependency result Three stacked bands showing request scope lasting one request, process scope shared by requests within one worker, and Redis shared across all workers. request scope built in, use_cache dies with the request request A request B no sharing process scope module global one copy per worker worker 1 requests worker 2: cold Redis scope shared, invalidatable costs a round trip every worker, one copy
The scope you pick decides who can see a stale value and who can invalidate it.

What FastAPI Already Does

FastAPI keeps a per-request cache of dependency results, keyed by the pair (callable, security_scopes). It lives in the dependency_cache dict that solve_dependencies threads through the resolution of one request's graph. When the same callable is reached again — from a second sub-dependency, from a router-level dependency, from the path operation itself — the stored value is returned instead of calling it again.

Two conditions control that. The dependency must be declared with use_cache=True, which is the default, and it must be the same callable object. Two separately defined functions with identical bodies are different keys; so is the same function wrapped twice by different decorators. This is why a dependency you refactored into a factory (def make_dep(x): def dep(): ...; return dep) suddenly starts running many times per request — every call to the factory produces a new object.

The important limit is scope. This cache is created when the request starts and discarded when it ends. It exists to stop redundant work inside one request, not to save work across requests. Anything longer-lived is yours to build.

Measuring the Three Scopes

The example below counts loads for real. load_permissions increments a counter on every call, so the transcript shows exactly how many times each strategy hit the "database".

from typing import Annotated

from fastapi import Depends, FastAPI

STATS = {"db_loads": 0, "settings_loads": 0, "process_cache_hits": 0, "process_cache_misses": 0}


async def load_permissions(user_id: str) -> list[str]:
    """Stand-in for the expensive call: one 'query' per invocation, counted."""
    STATS["db_loads"] += 1
    return ["admin"] if user_id == "u1" else ["read"]


async def current_user(user: str = "u1") -> str:
    """In a real app this reads a token; here it is a query param so the run is reproducible."""
    return user


async def permissions(user: Annotated[str, Depends(current_user)]) -> list[str]:
    return await load_permissions(user)


async def can_write(perms: Annotated[list[str], Depends(permissions)]) -> bool:
    return "admin" in perms


async def can_delete(perms: Annotated[list[str], Depends(permissions)]) -> bool:
    return "admin" in perms


@app.get("/request-scoped")
async def request_scoped(
    perms: Annotated[list[str], Depends(permissions)],
    write: Annotated[bool, Depends(can_write)],
    delete: Annotated[bool, Depends(can_delete)],
) -> dict[str, object]:
    return {"perms": perms, "write": write, "delete": delete, "db_loads": STATS["db_loads"]}

The uncached comparison needs use_cache=False at every site, including inside the sub-dependencies — that is the part people get wrong:

async def can_write_uncached(
    perms: Annotated[list[str], Depends(permissions, use_cache=False)],
) -> bool:
    return "admin" in perms


async def can_delete_uncached(
    perms: Annotated[list[str], Depends(permissions, use_cache=False)],
) -> bool:
    return "admin" in perms


@app.get("/no-cache")
async def no_cache(
    perms: Annotated[list[str], Depends(permissions, use_cache=False)],
    write: Annotated[bool, Depends(can_write_uncached, use_cache=False)],
    delete: Annotated[bool, Depends(can_delete_uncached, use_cache=False)],
) -> dict[str, object]:
    return {"perms": perms, "write": write, "delete": delete, "db_loads": STATS["db_loads"]}

Real output from two requests to the cached route followed by one to the uncached route:

$ GET /request-scoped
200 OK
{
  "perms": [
    "admin"
  ],
  "write": true,
  "delete": true,
  "db_loads": 1
}

$ GET /request-scoped
200 OK
{
  "perms": [
    "admin"
  ],
  "write": true,
  "delete": true,
  "db_loads": 2
}

$ GET /no-cache
200 OK
{
  "perms": [
    "admin"
  ],
  "write": true,
  "delete": true,
  "db_loads": 5
}

Three declarations, one load. The second request starts from a cold cache and adds exactly one more load — proof that nothing carries over. The uncached route jumps the counter from 2 to 5: three loads in a single request, one per declaration site.

Process Scope: Right for Config, Dangerous for Data

A module-level value that outlives the request is the cheapest possible cache — a dict lookup, no serialisation, no network. For genuinely global data such as configuration it is exactly right:

_settings: dict | None = None


async def get_settings() -> dict:
    global _settings
    if _settings is None:
        STATS["settings_loads"] += 1
        _settings = {"feature_x": True}
    return _settings

Two requests hit that endpoint and the transcript shows "settings_loads": 1 both times. Nothing subtle happens because the value does not depend on who is asking.

Now the trap. Here is the same pattern applied to per-user permissions, with a key that ignores the user:

_BAD_CACHE: dict[str, object] = {}


async def permissions_process_cached_wrong(
    user: Annotated[str, Depends(current_user)],
) -> list[str]:
    if "perms" in _BAD_CACHE:                      # BUG: one slot for every user.
        STATS["process_cache_hits"] += 1
        return _BAD_CACHE["perms"]
    STATS["process_cache_misses"] += 1
    _BAD_CACHE["perms"] = await load_permissions(user)
    return _BAD_CACHE["perms"]

The real output, with u1 (an admin) arriving first and u2 (read-only) second:

$ GET /perms-wrong?user=u1
200 OK
{
  "user": "u1",
  "perms": [
    "admin"
  ]
}

$ GET /perms-wrong?user=u2
200 OK
{
  "user": "u2",
  "perms": [
    "admin"
  ]
}

u2 was granted admin. Not stale data — someone else's data, used for an authorisation decision. This bug survives code review easily because the dependency signature takes user, so it looks user-aware; only the cache key is not. And it never shows up in local testing, where you are always the same user.

The fix is to key on every input the value depends on, and to add an expiry so a permission change eventually propagates:

import time

_GOOD_CACHE: dict[str, tuple[float, list[str]]] = {}
TTL_SECONDS = 30.0


async def permissions_process_cached(
    user: Annotated[str, Depends(current_user)],
) -> list[str]:
    entry = _GOOD_CACHE.get(user)
    if entry and (time.monotonic() - entry[0]) < TTL_SECONDS:
        STATS["process_cache_hits"] += 1
        return entry[1]
    STATS["process_cache_misses"] += 1
    value = await load_permissions(user)
    _GOOD_CACHE[user] = (time.monotonic(), value)
    return value

Real hit and miss counts across u1, u2, then u1 again:

$ GET /perms-keyed?user=u1
200 OK
{
  "user": "u1",
  "perms": [
    "admin"
  ],
  "cache": {
    "db_loads": 7,
    "settings_loads": 1,
    "process_cache_hits": 1,
    "process_cache_misses": 2
  }
}

$ GET /perms-keyed?user=u2
200 OK
{
  "user": "u2",
  "perms": [
    "read"
  ],
  "cache": {
    "db_loads": 8,
    "settings_loads": 1,
    "process_cache_hits": 1,
    "process_cache_misses": 3
  }
}

$ GET /perms-keyed?user=u1
200 OK
{
  "user": "u1",
  "perms": [
    "admin"
  ],
  "cache": {
    "db_loads": 8,
    "settings_loads": 1,
    "process_cache_hits": 2,
    "process_cache_misses": 3
  }
}

u2 gets read, correctly, at the cost of one miss. The repeat visit from u1 is a hit and db_loads does not move. Note time.monotonic() rather than time.time(): a monotonic clock cannot jump backwards when NTP adjusts the system time, which would otherwise make an entry look valid for hours.

When to Move the Cache to Redis

Process scope has one structural limit that no amount of careful keying fixes: each uvicorn worker has its own dict. With four workers behind a load balancer, one user's request can hit a worker with a fresh value and the next hit a worker with a two-minute-old one, and there is no way to invalidate all four when the underlying data changes. You can wait out a TTL, but you cannot make a write take effect immediately.

Move to Redis when either of those matters — when the value must be consistent across workers, or when a write must invalidate the cache now rather than in thirty seconds. The dependency shape barely changes:

import json


async def permissions_cached(
    user: Annotated[str, Depends(current_user)],
    redis: Annotated[Redis, Depends(get_redis)],
) -> list[str]:
    key = f"perms:v2:{user}"                  # Version in the key: a schema change is a new key.
    cached = await redis.get(key)
    if cached is not None:
        return json.loads(cached)
    value = await load_permissions(user)
    await redis.set(key, json.dumps(value), ex=300)
    return value

The v2 segment is worth the character cost. When the shape of the cached value changes, bumping the version invalidates everything atomically without a flush and without a deploy-order problem where old and new code disagree about what is stored. The write-side invalidation patterns — delete on write, tag-based grouping, and why they are harder than they look — are covered in cache invalidation patterns in FastAPI, and caching whole responses rather than dependency values is Redis response caching in FastAPI.

One correctness note that applies to every shared cache: a Redis round trip is I/O, so a cached dependency is still async def and still awaits. If your cache lookup is synchronous (a blocking Redis client, a file read) you have moved a blocking call into the dependency layer where it will stall the event loop for every request, not just the ones that miss — see fixing blocking calls in async routes.

The lru_cache Trap

functools.lru_cache is the obvious tool and it does not work on async dependencies. Calling an async def returns a coroutine object; lru_cache stores that object and hands the same one back on the next call. Awaiting an already-awaited coroutine raises RuntimeError: cannot reuse already awaited coroutine. The first request succeeds and the second fails, which makes it a genuinely nasty production bug — it passes any test that makes one call.

On a synchronous dependency it is fine and idiomatic, which is why the pattern is so common for settings:

from functools import lru_cache


@lru_cache
def get_settings() -> Settings:
    return Settings()          # Sync, no I/O, one instance for the process lifetime.

That is the standard approach described in managing environment variables with pydantic-settings. Keep lru_cache for that case and use an explicit dict with an expiry for anything awaited.

Verification

Count calls rather than trusting the code to be doing what you think:

def test_dependency_runs_once_per_request(client, monkeypatch):
    calls = []
    monkeypatch.setattr("app.deps.load_permissions", lambda u: calls.append(u) or ["admin"])
    client.get("/request-scoped")
    assert len(calls) == 1        # Three declarations, one call.


def test_cache_is_keyed_by_user(client):
    assert client.get("/perms-keyed?user=u1").json()["perms"] == ["admin"]
    assert client.get("/perms-keyed?user=u2").json()["perms"] == ["read"]

The second test is the one that catches the leak, and it is worth writing for every cached dependency whose value varies by caller. In production, export hit and miss counters per cache name; a hit rate that is suspiciously close to 100% on user-specific data is usually a missing key segment rather than good luck.

Trade-offs and When Not To

Caching a dependency trades freshness for latency, and at the dependency layer the thing you are usually caching is an authorisation decision. That makes the staleness window a security property: a revoked permission stays live for the length of your TTL. Thirty seconds is defensible; five minutes on an admin flag is how a fired employee keeps write access through their exit interview.

Do not cache when the underlying call is already fast. A primary-key lookup on a warm connection can be cheaper than a Redis round trip, and the cache then adds a network hop, a serialisation step, and a whole class of consistency bugs in exchange for nothing.

And do not reach for a process-scoped cache to fix a per-request problem. If the same value is being computed repeatedly inside one request, the built-in use_cache behaviour already handles it — the fix is to share one dependency object, not to add a global. Getting that structure right is the subject of best practices for FastAPI dependency injection and dependency caching and use_cache.

FAQ

Does FastAPI already cache dependency results? Yes, within a single request. A dependency declared many times in one request's graph is called once and the value reused, as long as use_cache is True (the default) and it is the same callable object. Nothing is shared between requests.

Why does use_cache=False on the top-level dependency not stop the caching? Because the flag applies per declaration site. Sub-dependencies that still declare the callable with the default use_cache=True keep sharing the cached value, so you must set the flag at every site in the graph to force a re-run.

What is wrong with caching per-user data in a module-level dict? If the cache key does not include the user, the first user's value is served to everyone until it expires. It is a data leak, not just a stale read, and it is invisible in single-user testing because you never see another user's data come back.

When should a dependency cache live in Redis rather than in the process? When the value must be consistent across workers or invalidated on write. A process-local cache means each uvicorn worker has its own copy with its own expiry, so an invalidation on one worker leaves the others serving stale data.

Is lru_cache safe on an async dependency? No. functools.lru_cache caches the coroutine object rather than its result, and awaiting the same coroutine twice raises a RuntimeError. Cache the awaited value in a dict, or apply lru_cache only to synchronous dependencies such as settings loaders.