FastAPI Caching with Redis

Some answers take real effort to produce: a heavy database query, a call to a slow partner service, a long calculation. When hundreds of visitors ask the same question, repeating that effort wastes time and money. A cache stores the finished answer in fast memory so the next visitor receives it instantly. Think of a chef who cooks a big pot of soup at noon. Each guest gets a ladle from the pot rather than a fresh bowl cooked from scratch.

What Redis Is

Redis is an in-memory data store. It keeps data in RAM, which makes reads and writes extremely quick. Redis stores simple key and value pairs, and each pair can carry an expiry time.

key                    value                        expires in
─────────────────────  ───────────────────────────  ──────────
product:42             {"id": 42, "name": "Lamp"}   60 seconds
top-sellers            [12, 7, 42, 19]              300 seconds

Starting Redis Locally

Docker offers the quickest way to run Redis on a laptop.

docker run -d --name my-redis -p 6379:6379 redis
pip install redis

The Cache-Aside Pattern

Most APIs follow one simple recipe called cache-aside. The route checks the cache first and visits the database only after a miss.

  Request arrives
        │
        ▼
  Look in Redis ──── found (hit) ───────────────▶ return cached answer
        │
     not found (miss)
        │
        ▼
  Query the database
        │
        ▼
  Save the answer in Redis with an expiry time
        │
        ▼
  Return the answer

Connecting from FastAPI

import json
import redis.asyncio as redis
from fastapi import FastAPI

app = FastAPI()
cache = redis.from_url("redis://localhost:6379", decode_responses=True)

The option decode_responses=True returns plain strings instead of raw bytes.

Caching a Route

import asyncio

async def load_product_from_db(product_id: int):
    await asyncio.sleep(2)            # pretend the query takes two seconds
    return {"id": product_id, "name": "Desk Lamp", "price": 35.0}

@app.get("/products/{product_id}")
async def get_product(product_id: int):
    key = f"product:{product_id}"

    cached = await cache.get(key)
    if cached:
        return json.loads(cached)

    product = await load_product_from_db(product_id)
    await cache.set(key, json.dumps(product), ex=60)
    return product
Call 1:  GET /products/42   ──▶  miss, database query    ──▶  about 2 seconds
Call 2:  GET /products/42   ──▶  hit, served from Redis  ──▶  a few milliseconds
...
After 60 seconds the key expires, and the next call loads fresh data.

The argument ex=60 tells Redis to delete the key after sixty seconds.

Keeping the Cache Fresh

Cached data can drift away from the truth. Two habits keep it honest.

Expiry Times

Short lifetimes limit how stale an answer can become. Pick the lifetime by asking how much delay the business can tolerate. A news headline list may live for one minute. A country list may live for a day.

Deleting on Change

Remove the cached copy whenever the source record changes.

@app.put("/products/{product_id}")
async def update_product(product_id: int, data: dict):
    # ...save changes to the database...
    await cache.delete(f"product:{product_id}")
    return {"updated": True}

Designing Good Keys

  • Use a readable pattern such as type:id.
  • Include every input that changes the answer, such as products:page=2:size=10.
  • Add the user ID to keys that hold personal data.

Connection Management

Create the Redis client once and reuse it. Close it at shutdown through a lifespan function.

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await cache.aclose()

app = FastAPI(lifespan=lifespan)

What to Cache and What to Skip

Good candidates                    Poor candidates
─────────────────────────────      ─────────────────────────────
Popular product pages              Bank balances
Lists that change rarely           One-time passwords
Results of heavy reports           Data unique to each request
Responses from slow partners       Anything that must be live

Common Mistakes

Caching Private Data Under a Shared Key

One user's profile served to another user is a serious leak. Put the user ID in the key.

Forgetting Expiry

Keys without a lifetime pile up and fill memory.

Trusting the Cache Blindly

Redis can restart and lose data. Treat the cache as a speed boost, and keep the database as the source of truth.

A Simple Speed Check

Measure the gain with a short test. Call the cached route twice and compare the response times in the interactive docs or with a command line tool.

curl -w "time: %{time_total}s
" -o /dev/null -s http://127.0.0.1:8000/products/42

The first call reports about two seconds because the route reaches the slow data source. The second call reports a few thousandths of a second. The numbers show why large websites cache aggressively: a single slow query serves thousands of visitors after one calculation.

Key Points

  • A cache stores finished answers in fast memory.
  • Redis holds key and value pairs with optional expiry times.
  • Cache-aside checks the cache first and fills it after a miss.
  • Delete or expire entries so users never see old data for long.
  • Build keys that include every input affecting the answer.