Caching Python API Responses with Redis for Faster, Cheaper Endpoints

A read-heavy endpoint that recomputes the same JSON on every request burns CPU, hammers your database, and inflates your hosting bill. Redis fixes that: you store the computed response once, serve it from memory for a few seconds or minutes, and let your upstream breathe. This guide walks through wiring an async Redis cache into a FastAPI service end to end — connection setup, a reusable caching dependency, the cache-aside pattern, stampede protection, and the hit-ratio math that tells you whether any of it is paying off. Part of the Scaling and Operating Production Python APIs guide.

When your cached data changes and you need to decide how to evict it, jump to cache invalidation strategies — this page focuses on getting a correct, fast cache in place first. If you are still weighing whether you even need a network round-trip to Redis versus caching inside each worker, read Redis vs in-memory caching for FastAPI alongside this guide.

Cache-aside data flow An incoming request checks Redis. A hit returns cached JSON immediately. A miss queries the database, populates Redis with a TTL, then returns the response. Request Check Redis by cache key HIT Return cached JSON MISS Query DB Populate Redis with TTL, then return

What actually belongs in a Redis cache

Before writing a line of code, be honest about what you are caching and why. A cache earns its keep on data that is read far more often than it changes and is expensive to produce relative to a memory lookup. A product catalogue queried ten thousand times an hour and edited twice a day is the textbook case. A per-user notification feed that changes on every request is not — you would spend more effort invalidating it than you ever save.

The three properties that make a response cacheable are worth naming explicitly. First, it must be deterministic for a given key: the same inputs produce the same output, so a stored copy is safe to reuse. Second, it must be safe to serve slightly stale — every cache trades freshness for speed, and if your business cannot tolerate a value being a few seconds behind, caching is the wrong tool and you want a materialized read model instead. Third, the cost of producing it must dwarf the cost of a Redis round-trip. A Redis GET over a local network is roughly 0.2–0.5 ms; if your loader also runs in under a millisecond, the cache adds latency rather than removing it.

The best caching candidates in a typical commercial API are aggregation endpoints (dashboards, counts, leaderboards), responses assembled from a slow third-party API you pay per call, and expensive database joins that fan out across several tables. The worst candidates are write endpoints, anything personalized without a per-user key, and cheap primary-key lookups that Postgres already answers from its own buffer cache in microseconds. Cache the expensive reads, leave the cheap ones alone, and never cache a write.

There is also a strategic reason to reach for Redis specifically rather than any other store. Redis gives you atomic operations (SET NX, INCR, HINCRBY) that a plain key-value store does not, and those primitives are exactly what stampede protection and hit-ratio counters are built from later in this guide. You are not just buying a fast dictionary; you are buying the atomic building blocks that make a correct cache under concurrency, which is why it stays the default even as your API grows from one instance to a fleet.


Prerequisites

You need a running Redis server and the async client. Redis 7.x is the safe baseline; locally, docker run -p 6379:6379 redis:7 is enough. Install the modern redis package (the old aioredis is now merged into it):

Bash
pip install "redis>=5.0" fastapi "uvicorn[standard]"

This guide assumes you already have a FastAPI app — if not, start with setting up FastAPI. When the loader behind the cache is a database query, wire it through an async engine as covered in async database access with SQLAlchemy so the miss path never blocks the event loop. When the loader calls a third-party API, use an async HTTP client — see httpx vs requests for async.

Configuration comes from environment variables only — never hardcode connection strings:

Bash
export REDIS_URL="redis://localhost:6379/0"
export CACHE_TTL_SECONDS="60"
export CACHE_NAMESPACE="api:v1"

Step 1: Connect with redis.asyncio

Create one connection pool for the whole process and reuse it. Opening a fresh connection per request defeats the point of caching and, under load, exhausts your Redis connection limit the same way an unbounded database pool does — a failure mode covered in fixing connection pool exhaustion. Read the URL from the environment and decode responses so you get str back instead of bytes:

Python
import os
import redis.asyncio as redis

REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")

pool = redis.ConnectionPool.from_url(REDIS_URL, decode_responses=True)


def get_redis() -> redis.Redis:
    """Return a client bound to the shared pool."""
    return redis.Redis(connection_pool=pool)

Wire a health check into your FastAPI lifespan so the app fails fast if Redis is unreachable rather than discovering it on the first cache miss:

Python
from contextlib import asynccontextmanager
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    client = get_redis()
    await client.ping()
    yield
    await pool.aclose()


app = FastAPI(lifespan=lifespan)

One decision you must make on day one is what happens when Redis is down in production. The default here fails startup, which is correct — but a cache should never be a hard dependency for serving traffic. Once you are live, wrap the cache read in a try/except redis.RedisError and fall through to the loader on any Redis error, logging the failure. A cache outage should degrade you to "slow but correct", never to a 500. The ping() on startup catches misconfiguration; the runtime fallback catches a mid-day Redis restart.


Step 2: Build a cache key and serialize JSON

A cache entry needs a stable, collision-free key. Namespace it so you can flush a version cleanly, and fold in any query parameters that change the response. Store the value as a JSON string — Redis stores text, not Python objects.

Python
import hashlib
import json
import os

NAMESPACE = os.getenv("CACHE_NAMESPACE", "api:v1")


def cache_key(route: str, **params) -> str:
    """Deterministic key from route + sorted params."""
    raw = json.dumps(params, sort_keys=True, separators=(",", ":"))
    digest = hashlib.sha256(raw.encode()).hexdigest()[:16]
    return f"{NAMESPACE}:{route}:{digest}"

Sorting the params keeps ?a=1&b=2 and ?b=2&a=1 mapping to the same key. Truncating the SHA-256 digest keeps keys short while staying effectively collision-free for this use — a 16-hex-character prefix is 64 bits of entropy, so you would need billions of distinct parameter sets on a single route before a collision is even statistically plausible.

The namespace prefix is the cheapest invalidation tool you have. Bumping api:v1 to api:v2 on a deploy orphans every old key at once, and because they still carry TTLs, Redis evicts them on its own within minutes. That single-character change is often all the "invalidation" a small API needs; the heavier, event-driven approaches only earn their complexity once staleness starts costing you money, which is exactly the trade-off weighed in cache invalidation strategies.


Step 3: Implement the cache-aside pattern

Cache-aside (also called lazy loading) is the default for read-heavy APIs: on a request, look in Redis first; on a miss, compute the value, write it back with a TTL, and return it. The compute function stays ignorant of caching, which keeps it testable in isolation.

Python
from typing import Awaitable, Callable

CACHE_TTL = int(os.getenv("CACHE_TTL_SECONDS", "60"))


async def cache_aside(
    client: redis.Redis,
    key: str,
    loader: Callable[[], Awaitable[dict]],
    ttl: int = CACHE_TTL,
) -> tuple[dict, bool]:
    """Return (value, hit). On miss, run loader and populate Redis."""
    cached = await client.get(key)
    if cached is not None:
        return json.loads(cached), True

    value = await loader()
    await client.set(key, json.dumps(value), ex=ttl)
    return value, False

Use it from a route. The loader is your real data source — a database query, an aggregation, or an upstream API call:

Python
from fastapi import Depends, Response


@app.get("/products/{product_id}")
async def get_product(
    product_id: int,
    response: Response,
    client: redis.Redis = Depends(get_redis),
):
    key = cache_key("product", id=product_id)

    async def loader() -> dict:
        # Replace with your DB query or upstream call
        return await fetch_product_from_db(product_id)

    data, hit = await cache_aside(client, key, loader)
    response.headers["X-Cache"] = "HIT" if hit else "MISS"
    return data

The X-Cache header makes hits observable from the outside, which you'll use in verification below. It also matters commercially: when a customer complains their data looks stale, that header instantly tells you whether they hit a cached copy or a fresh compute, turning a vague "it's wrong" ticket into a two-minute diagnosis.

One subtle correctness rule lives in this pattern: never cache under a key that does not capture every input that varies the output. If your endpoint returns different data by Accept-Language, tenant, or currency, those must all be folded into cache_key(...). A key that is too coarse serves the wrong body to the wrong caller — the same class of bug as caching authenticated responses, covered in the gotchas below.


Step 4: Add stampede protection with a lock

When a hot key expires, every concurrent request misses at once and all of them slam the database — a thundering herd. On a single popular endpoint this can turn a routine TTL expiry into a self-inflicted outage: one moment you serve everything from memory, the next you fire hundreds of identical expensive queries in the same tick. Guard the recompute with a short-lived Redis lock so only one request rebuilds the value while the others wait briefly and re-read:

Python
import asyncio
import uuid


async def cached_with_lock(
    client: redis.Redis,
    key: str,
    loader: Callable[[], Awaitable[dict]],
    ttl: int = CACHE_TTL,
    lock_ttl: int = 5,
) -> tuple[dict, bool]:
    cached = await client.get(key)
    if cached is not None:
        return json.loads(cached), True

    lock_key = f"{key}:lock"
    token = uuid.uuid4().hex
    got_lock = await client.set(lock_key, token, nx=True, ex=lock_ttl)

    if not got_lock:
        # Someone else is rebuilding; wait and re-read.
        for _ in range(lock_ttl * 10):
            await asyncio.sleep(0.1)
            cached = await client.get(key)
            if cached is not None:
                return json.loads(cached), True

    try:
        value = await loader()
        await client.set(key, json.dumps(value), ex=ttl)
        return value, False
    finally:
        # Only release if we still own the lock.
        if await client.get(lock_key) == token:
            await client.delete(lock_key)

The nx=True flag makes the set atomic — exactly one caller wins the lock. The token check on release prevents one request from deleting a lock that another already re-acquired after a timeout. That token is not paranoia: without it, a slow loader that overruns lock_ttl lets a second request grab the lock, and the first request's finally block would then delete a lock it no longer owns, defeating the whole guard.

The sequence below traces three requests hitting an expired key at the same instant. Only Request A does real work; B and C poll and get served the value A just wrote.

Stampede protection sequence Three requests miss an expired key at once. Request A wins the Redis lock, queries the database, and writes the value. Requests B and C fail the lock, poll Redis, and are served the value A wrote. Request A Requests B, C Redis Database key expired — all three miss SET NX lock — won SET NX lock — lost A loads from source of truth SET value, EX=ttl B, C poll every 100 ms GET key — HIT one DB query served all three requests

The polling loop is deliberately simple. A busier service can swap the fixed 100 ms sleep for exponential backoff, but at the traffic levels where a single Redis lock matters, a tenth-of-a-second poll for a handful of iterations is plenty and keeps the code obvious.


Configuration reference

Env varDefaultProduction recommendation
REDIS_URLredis://localhost:6379/0Managed Redis with TLS: rediss://...; pin a dedicated DB index
CACHE_TTL_SECONDS6030–300s for hot reads; tune per endpoint, not globally
CACHE_NAMESPACEapi:v1Bump the version suffix to invalidate every key on deploy
CACHE_LOCK_TTL5Slightly above your p99 loader latency so the lock never expires mid-rebuild
REDIS_MAX_CONNECTIONSpool defaultCap near your worker count × concurrency to avoid exhausting Redis

Two of these deserve a second look. CACHE_LOCK_TTL must sit above your slowest realistic loader run, or the lock expires while A is still rebuilding and B grabs it and starts a second rebuild — reintroducing the exact stampede you were preventing. Set it from real p99 numbers, not a guess. REDIS_MAX_CONNECTIONS matters most on serverless or autoscaled hosts, where every replica opens its own pool; multiply pool size by replica count and confirm the total stays under your managed Redis plan's connection ceiling, since blowing past it fails requests rather than slowing them.


Measuring hit ratio and tuning TTL

Everything above is theatre until you measure the one number that decides whether caching pays: the hit ratio, hits / (hits + misses). It maps directly onto how much load reaches your database, and therefore onto your bill. The chart below plots, per 1,000 incoming requests, how many actually reach the database at different hit ratios.

Database load falls as hit ratio rises A bar chart of database queries per 1000 incoming requests. At 0 percent hit ratio all 1000 reach the database; at 50 percent 500; at 70 percent 300; at 90 percent 100; at 95 percent 50. Database queries per 1,000 requests, by cache hit ratio 1000 0% 500 50% 300 70% 100 90% 50 95% cache hit ratio → fewer queries reach the database

The curve is steep where it counts. Moving from no cache to a 90% hit ratio cuts database load by a factor of ten — that is the jump from all 1,000 requests hitting Postgres to just 100. But the last stretch is expensive: squeezing 90% up to 95% only halves the remaining load, from 100 queries to 50, and usually costs you a much longer TTL and staler data to get there. Chase the first order of magnitude aggressively; treat the last few points as diminishing returns.

Instrument it with a pair of counters so the number is never a guess:

Python
async def record(client: redis.Redis, hit: bool) -> None:
    field = "hits" if hit else "misses"
    await client.hincrby(f"{NAMESPACE}:stats", field, 1)

Read those two counters on a schedule, compute the ratio, and ship it to your dashboards — the mechanics of that live in monitoring and logging Python APIs, and attaching hit/miss context to each request line is exactly what structured logging with structlog is for. If the ratio sits below ~70%, your TTL is too short for the traffic pattern or your key cardinality is too high — you are minting a fresh key per request and never reusing one.


Gotchas and failure modes

  • Caching authenticated responses. Never cache a personalized payload under a shared key — user A will be served user B's data. Fold the user or tenant id into the cache key, or skip the cache entirely for private endpoints. This is a security bug, not a performance footgun.
  • Serializing Pydantic models. json.dumps() chokes on a Pydantic model. Call model.model_dump(mode="json") before storing and rebuild with Model.model_validate(...) on read so dates and enums round-trip cleanly.
  • TTL vs invalidation. A TTL bounds staleness but doesn't guarantee freshness — a value can be wrong for up to its full TTL. When correctness matters more than a few seconds of lag, you need active eviction; that trade-off is the whole subject of cache invalidation strategies.
  • Thundering herd on cold start. After a deploy or a Redis flush, every key is empty and the lock from Step 4 is your only defense. Without it, a popular endpoint can take down your database in the first second of traffic.
  • Caching errors. If your loader raises, don't write a placeholder into Redis. Let the exception propagate so the next request retries instead of serving a cached failure for the full TTL.
  • Unbounded memory. Every key without a TTL is a permanent tenant of your Redis instance. Set maxmemory and an eviction policy (allkeys-lru) on the server so a bug that writes non-expiring keys degrades gracefully instead of OOM-killing Redis.

Verification

Start the app, hit the endpoint twice, and watch the header flip from MISS to HIT:

Bash
curl -i http://localhost:8000/products/42 | grep -i x-cache
# X-Cache: MISS
curl -i http://localhost:8000/products/42 | grep -i x-cache
# X-Cache: HIT

Confirm the key landed in Redis and inspect its remaining TTL:

Bash
redis-cli KEYS 'api:v1:product:*'
# 1) "api:v1:product:9f4c1a2b3d5e6f70"
redis-cli TTL api:v1:product:9f4c1a2b3d5e6f70
# (integer) 58

A live TTL counting down confirms the write succeeded with expiry. If you see -1, you stored the key without ex= and it will never expire — a silent memory leak. Bake this into your test suite rather than checking it by hand: an integration test that calls the endpoint twice and asserts X-Cache flips from MISS to HIT, then asserts a non-negative TTL on the key, catches a broken cache before it ships. Wiring that against a real Redis is covered in testing Python APIs with pytest.


Cost and performance note

Caching trades a little Redis memory for a lot of saved compute. The lever is your hit ratio: at a 90% hit ratio, nine of ten requests skip the database and return from memory in sub-millisecond time, so your upstream sees a tenth of the load. For a managed Postgres or a metered upstream API, that's a direct, linear reduction in your most expensive resource — often the difference between staying on a small instance and paying for a bigger one.

Put real numbers on it. Suppose an endpoint serves 5 million requests a month and each uncached response costs a 15 ms database query plus its share of a $50/month Postgres plan running near capacity. At a 90% hit ratio you drop from 5 million queries to 500,000, which typically lets that same plan coast at 10% utilization — you defer the upgrade to the next tier indefinitely. When the loader is a paid third-party API billed per call, the saving is even starker: caching an upstream that charges $0.50 per thousand calls at a 90% hit ratio turns a $2,500 monthly bill into $250. That is why caching is also a pricing lever — it lets you offer cheaper, higher-limit tiers without proportional infrastructure cost, and the same discipline is what keeps LLM API costs under control in production when responses are expensive to generate. Turning that efficiency into margin is the subject of designing API pricing tiers.


FAQ

Should I cache POST or write requests? No. Cache only safe, idempotent reads — GET requests with no side effects. Writes must hit your source of truth every time, and they're what invalidate cached reads. Caching a write would serve a customer a stale confirmation of a change that never happened.

How do I pick a TTL? Start from how stale the data is allowed to be, not from a round number. Pricing or inventory that changes hourly tolerates a 60–300s TTL; a slowly-changing reference list can sit at an hour or more. Shorter TTLs lower staleness but raise miss rate and upstream load, so the TTL is really a dial between your compute bill and your freshness guarantee — set it per endpoint, never globally.

Why use Redis instead of in-process caching like lru_cache?lru_cache lives in one worker's memory, so every worker and every replica caches independently and you get inconsistent results plus N times the upstream load. Redis is shared across all workers and survives restarts, which is what you need in production. The full trade-off, including when a local cache genuinely wins on latency, is in Redis vs in-memory caching for FastAPI.

What does this cost to run at scale, and what's the migration risk? A managed Redis with 256 MB is a few dollars a month and holds hundreds of thousands of small JSON entries — trivial next to the database and compute it saves. The migration risk is low because cache-aside is additive: you wrap existing reads, and if Redis disappears you fall through to the loader. The one hazard is a coarse cache key leaking data across tenants, so audit keys before you cache anything user-specific.

Does decode_responses hurt performance? Negligibly. It decodes bytes to str for you so your JSON handling is clean. If you cache binary blobs or want to store compressed payloads, drop it and decode selectively instead.


Same track:

Other tracks: