Redis Response Caching in FastAPI
Key takeaways:
- Sort the query-string pairs before they enter the key, or equivalent requests silently occupy separate entries and never hit.
- Any response that varies per user needs the principal in the key; without it the cache is a data-leak mechanism.
- Store the serialized JSON so a hit skips re-serialization as well as the query.
- Wrap every cache call so a Redis outage degrades latency instead of returning errors.
- Invalidate on write and keep the TTL as a backstop, not as the primary freshness mechanism.
This guide implements response-level caching from Caching Strategies. It is about the key and the failure behaviour; the complementary problem of removing entries correctly is covered in cache invalidation patterns, and caching inside the dependency graph rather than at the response boundary is covered in caching dependency results.
What Was Executed on This Page
There is no Redis server in this site's verification environment. Inventing a redis-cli session would be exactly the kind of imagined output this site exists to avoid, so the example below runs against an in-process store exposing the same async get / set / delete surface that redis.asyncio.Redis exposes, and the transcript is a real run of the FastAPI code against it.
That is not a shortcut. Every bug in this pattern is on the application side of that interface: the key you derived, the principal you forgot, the exception you did not catch. Redis's own behaviour — memory limits, eviction policy, persistence, cluster key distribution — is described in prose below with no fabricated numbers attached to it.
The Problem This Solves
A catalogue endpoint queries a handful of tables, builds a few hundred nested models, and serializes them to a payload that has not changed in an hour. Every request pays the full cost. The database is not struggling yet, but the p99 is ugly and the serialization work is burning event-loop time that other requests need.
Response caching converts that into a single read of a string. The hard part is not the read. It is making sure the string you read is the right one for this request, and making sure the whole thing collapses gracefully when the cache is not there.
Why It Happens: The Key Is the Whole Design
A cache is a pure function from key to value, and every property you want from it is a property of how you compute that key.
Take the query string first. request.query_params preserves the order the client sent, and ?a=1&b=2 is a semantically identical request to ?b=2&a=1. Fold the raw string into the key and those two requests occupy separate entries, each with a miss rate of one hundred percent from the perspective of the other. Multiply that by three optional filters and you have a cache that stores a great deal and hits almost nothing. Sorting the pairs before encoding collapses them, and it costs nothing.
The same reasoning applied to identity gives you the more serious rule. If the handler's output depends on who is asking, and the principal is not in the key, then the first user to warm a key hands their data to everyone who follows. This is not an exotic failure — it is what happens by default the first time someone adds caching to an endpoint that reads current_user. The safe habit is to make the key derivation function require a scope argument, so that caching a per-user endpoint without thinking about it is a type error rather than an incident.
The third property is what happens when Redis is unreachable. A cache is an optimisation, and an optimisation that can take down the API is a liability. redis.asyncio raises ConnectionError on a failed round trip, and if that propagates out of a dependency your endpoint returns 500 for a request the database could have answered perfectly well. Each cache interaction therefore needs its own guard: a failed read means "miss", and a failed write means "we did not cache it this time".
There is a fourth property worth naming, which is that the cache must be async. redis-py's synchronous client blocks the event loop for the duration of the round trip, and a blocking call inside an async def handler stalls every other request on that worker — the mechanism is spelled out in fixing blocking calls in async routes. Use redis.asyncio.Redis, always.
The Fix
Own the client in lifespan and expose it through a dependency so tests can replace it:
# app/cache.py
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from redis.asyncio import Redis
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.redis = Redis.from_url(
app.state.settings.redis_url,
decode_responses=True,
socket_timeout=0.25, # a slow cache must not become a slow API
socket_connect_timeout=0.25,
)
try:
yield
finally:
await app.state.redis.aclose()
def get_cache(request: Request) -> Redis:
"""A dependency, not a global: dependency_overrides can swap it in tests."""
return request.app.state.redis
The timeouts matter as much as the guards. Without them a Redis instance that is reachable but wedged makes every request wait on a TCP read, and the "optional" cache becomes the thing holding your latency hostage.
Now the key and the cache-aside helper:
import json
from typing import Any, Awaitable, Callable
from urllib.parse import urlencode
from redis.asyncio import Redis
from redis.exceptions import RedisError
def cache_key(request: Request, scope: str | None) -> str:
"""`scope` is mandatory as a parameter — pass None only for genuinely public data."""
qs = urlencode(sorted(request.query_params.multi_items()))
prefix = f"u={scope}:" if scope else ""
return f"resp:{prefix}{request.url.path}?{qs}"
async def cached_json(
cache: Redis, key: str, loader: Callable[[], Awaitable[Any]], ttl: int
) -> tuple[Any, bool]:
try:
raw = await cache.get(key)
if raw is not None:
return json.loads(raw), True # hit: no query, no re-serialization
except RedisError:
return await loader(), False # degraded: correct, just slower
value = await loader()
try:
await cache.set(key, json.dumps(value), ex=ttl)
except RedisError:
pass # not cached this time; the response is fine
return value, False
Returning (value, hit) rather than just the value is a small decision that pays for itself: the endpoint can set an X-Cache: HIT header, and your tests can assert on cache behaviour without reaching into Redis.
Wiring it into routes:
@router.get("/catalog")
async def catalog(request: Request, cache: Redis = Depends(get_cache)) -> dict:
body, hit = await cached_json(cache, cache_key(request, None), load_catalog, ttl=300)
return body
@router.get("/me/orders")
async def my_orders(
request: Request,
cache: Redis = Depends(get_cache),
user: User = Depends(current_user),
) -> dict:
key = cache_key(request, user.id) # scoped: never served to another user
body, hit = await cached_json(cache, key, lambda: load_orders(user.id), ttl=60)
return body
The Behaviour, Executed
This is a real run of that code against the in-process store described above. The first two requests send the same two query parameters in opposite orders:
$ GET /catalog?b=2&a=1
200 OK
{
"key": "resp:/catalog?a=1&b=2",
"cached": false,
"loads_so_far": 1,
"body": {
"items": [
"widget",
"gasket"
],
"revision": 1
}
}
$ GET /catalog?a=1&b=2
200 OK
{
"key": "resp:/catalog?a=1&b=2",
"cached": true,
"loads_so_far": 1,
"body": {
"items": [
"widget",
"gasket"
],
"revision": 1
}
}
Both produced resp:/catalog?a=1&b=2, so the second request hit. loads_so_far does not advance, which is the assertion that matters: the loader was not called. Remove the sorted() and the second key becomes ?b=2&a=1, cached stays false, and nothing else about the page changes — which is precisely why this bug survives code review.
Per-principal scoping is driven by issuing real requests with different auth headers back into the same app, since the harness's request tuples carry no headers:
$ GET /two-users
200 OK
{
"responses": [
{
"sent_as": "alice",
"key": "resp:u=alice:/me/orders?",
"cached": false,
"body": {
"owner": "alice",
"orders": [
"alice-order-1"
]
}
},
{
"sent_as": "bob",
"key": "resp:u=bob:/me/orders?",
"cached": false,
"body": {
"owner": "bob",
"orders": [
"bob-order-1"
]
}
},
{
"sent_as": "alice",
"key": "resp:u=alice:/me/orders?",
"cached": true,
"body": {
"owner": "alice",
"orders": [
"alice-order-1"
]
}
}
]
}
Same path, same empty query string, different keys. Bob's request missed rather than hitting Alice's warm entry, and Alice's repeat request hit her own. Drop the u= segment and the middle response would have carried alice-order-1 to Bob with a 200 and no error anywhere.
A write invalidates, and then the cache demonstrates that it is genuinely optional:
$ POST /catalog/items
200 OK
{
"invalidated_keys": [
"resp:/catalog?a=1&b=2"
],
"removed": 1
}
$ POST /break-cache
200 OK
{
"redis_reachable": false
}
$ GET /catalog?a=1&b=2
200 OK
{
"key": "resp:/catalog?a=1&b=2",
"cached": false,
"loads_so_far": 5,
"body": {
"items": [
"widget",
"gasket"
],
"revision": 5
}
}
After the store is switched to raise on every operation, the endpoint still answers 200 with a correct body. cached is false and the loader ran. That is the whole contract: when the cache goes away, the API gets slower and stays right.
The accounting confirms it end to end:
$ GET /stats
200 OK
{
"hits": 2,
"misses": 4,
"loader_invocations": [
"catalog",
"orders:alice",
"orders:bob",
"catalog",
"catalog"
],
"keys": [
"resp:/catalog?a=1&b=2",
"resp:u=alice:/me/orders?",
"resp:u=bob:/me/orders?"
]
}
Three keys for three distinct logical resources, and no fourth key produced by query-order noise.
Verification
The test that matters is the one asserting the loader did not run, because a cache that returns the right answer while still querying the database is indistinguishable from a working one at the response level:
async def test_repeat_request_does_not_touch_the_database(client, load_spy):
await client.get("/catalog?a=1&b=2")
load_spy.reset()
resp = await client.get("/catalog?b=2&a=1") # deliberately reordered
assert resp.status_code == 200
assert load_spy.calls == 0 # fails the moment sorting is removed
async def test_cache_is_scoped_per_user(client):
alice = await client.get("/me/orders", headers={"authorization": ALICE})
bob = await client.get("/me/orders", headers={"authorization": BOB})
assert bob.json()["owner"] == "bob" # not alice, ever
async def test_redis_outage_degrades_not_fails(client, broken_cache):
resp = await client.get("/catalog")
assert resp.status_code == 200
Reordering the query string in the first test is the detail worth copying. Without it the test passes against a naive key implementation and proves nothing.
In production, export a hit-rate metric and a cache-error counter separately, per Prometheus metrics for FastAPI. A hit rate that is low but non-zero usually means TTLs are shorter than the traffic's inter-arrival time; a hit rate near zero on a busy endpoint almost always means the key varies. Log a sample of generated keys for one minute and the cause is visible immediately.
Trade-offs and When Not To Use This
Response caching welds the cached value to one response shape. Change a field name in the model and every entry becomes stale in a way the TTL will not tell you about — the cached JSON is still valid JSON, just the old contract. Include a schema version in the key prefix so a deploy invalidates the entries it invalidated in reality.
Invalidation is harder than the caching. Deleting one key is easy; knowing every key a write affects is not, especially once filters and pagination multiply the key space for a single logical resource. Versioned key prefixes exist precisely because enumerating derived keys does not scale, and that is the subject of cache invalidation patterns.
A hot key expiring under load stampedes. Every concurrent request misses simultaneously and every one of them runs the loader, so the moment of expiry is the moment of maximum database load. A short lock or a probabilistic early refresh is the standard defence, covered in Caching Strategies.
Per-user caching often is not worth it. Scoping the key by principal divides the hit rate by the number of active users. For a personalised endpoint whose visitors mostly arrive once and do not come back, you are paying Redis memory and code complexity to serve almost nothing from cache. Measure the repeat-request rate per user before adding it.
Do not cache what you cannot afford to serve stale. Balances, permissions and anything a user just changed themselves belong outside this pattern, or behind an invalidation you are confident in. The user who updates their profile and sees the old value has lost more trust than the latency win was worth.
FAQ
Should I cache the serialized JSON or the model? For response caching, store the serialized JSON so a hit also skips re-serialization, which dominates the cost for large nested payloads. The trade-off is that the cached value is welded to that response shape, so if several endpoints need the same data in different shapes, caching the row or the model is more reusable.
How do I build a cache key that does not collide?
Combine the route path, the path parameters, and the query string with its pairs sorted, so a=1&b=2 and b=2&a=1 resolve to a single entry. For anything user-specific, add the authenticated principal to the key as its own segment.
What happens if Redis is down? Whatever you make happen. Wrap every cache call so a connection error is swallowed and the request falls through to the database, returning the correct answer more slowly. A cache outage must degrade latency and never availability, so no cache failure should ever reach the client.
Why does my cache never hit even though I am storing values? Almost always the key varies when you did not expect it to. An unsorted query string, a timestamp or request id folded into the key, or a per-request object rendered with its memory address all produce a fresh key every time, so every write is immediately orphaned.
Should the cache be a dependency or a decorator?
A dependency, in FastAPI. A decorator on the path operation function has to reconstruct the request to build a key and it fights FastAPI's signature inspection, while a dependency receives the Request naturally and is replaceable in tests through dependency_overrides.
How long should the TTL be? Set it from how stale the data may safely be, not from how expensive the query is. Start from the business tolerance, then treat invalidation on write as the mechanism that keeps the common case fresh and the TTL as the backstop for the invalidations you missed.
Related Reading
- Up to the topic: Caching Strategies for cache-aside, scopes and stampede control.
- Removing entries correctly: Cache Invalidation Patterns in FastAPI.
- Caching inside the dependency graph: Caching Dependency Results.
- Why the client must be async: Fixing Blocking Calls in Async Routes.
- Measuring the hit rate: Prometheus Metrics for FastAPI.