Caching Responses with Redis and FastAPI
Learn to cache FastAPI responses with Redis — speed up your APIs, reduce load, and improve user experience. This lesson covers the core concepts, a hands-on walkthrough, and common pitfalls.
Focus: caching responses with redis and fastapi
Your FastAPI endpoint is fast, but when a thousand users hit your product details endpoint at the same moment, your database starts sweating—and response times crawl from milliseconds to seconds. The fix isn't faster database queries; it's caching responses with Redis and FastAPI. By the end of this lesson, you'll be able to add a Redis-backed cache to your routes that slashes latency and keeps your database happy, even under heavy load.
The problem this lesson solves
We've all seen it: a perfectly written FastAPI route that performs a slow query, calls an external API, or renders a complex report. Every single request repeats that expensive work, even when the underlying data hasn't changed. This wastes CPU cycles, saturates your database connection pool, and delivers a sluggish experience to users waiting for data that could have been served instantly.
Caching responses with Redis and FastAPI solves this by storing the result of an expensive operation in a high-speed key-value store. When the same request comes in again, FastAPI can return the cached response in microseconds instead of redoing the heavy work. This isn't just an optimization—it's a fundamental tool for building APIs that scale. And it ties directly to the rest of your FastAPI journey: once you master caching, topics like rate limiting and session management (both often Redis-backed) become natural next steps.
Core concept / mental model
Think of Redis as a tiny, lightning-fast storage locker next to your FastAPI app. When a request arrives, your app first checks the locker: if the key (say, product:123) is there, grab it and go. If not, do the expensive work (query the DB, call an API), store the result in the locker with a time-to-live (TTL), and return it. This pattern is called a cache-aside strategy, and it's the most common way to combine Redis and FastAPI.
Let's get the vocabulary straight:
- Cache key: a unique string that identifies the cached data (e.g.,
user:42:profile). - Cache value: the serialized response—often JSON, but it can be any string.
- Time-to-live (TTL): how long the cache entry lives, in seconds. After the TTL expires, the entry disappears, and the next request repopulates it.
- Cache hit: when the requested key exists in Redis.
- Cache miss: when the key doesn't exist, forcing your app to compute the response.
The beauty of this model is that it doesn't change your API contract. Your routes still return the same Pydantic models and status codes; you're just adding a fast path between the client and your business logic. The client can't tell the difference—except that the response comes back faster.
How it works step by step
Let's lay out the flow that every @cache_response-style operation follows:
- Request arrives at your FastAPI endpoint.
- Build a cache key from the request parameters (e.g.,
endpoint:pathplus query params). - Try to get the value from Redis using that key.
- If it's a hit (key exists), return the cached value immediately, skipping the expensive logic.
- If it's a miss (key doesn't exist), run the normal response logic.
- Store the response in Redis with a key and a TTL.
- Return the response to the client—and ensure the same response object can be cached and later reused.
Implementing this by hand in every route is repetitive, so we'll create a small decorator that wraps the logic. This keeps your endpoints clean and makes the caching behavior opt-in—you decide which routes to cache and for how long.
The cause-and-effect chain is simple: fewer repeated computations → less load on your database and CPU → faster median and p95 latency → happier users and lower infrastructure costs.
Hands-on walkthrough
We'll build a real example: a FastAPI app that caches a fake database response with Redis. You'll need fastapi, redis-py, and uvicorn installed. Let's start with the Redis client setup.
Step 1: Set up Redis and a client
# cache.py
import redis
# Connect to Redis (local install or a container)
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
# Quick ping to verify the connection
print(redis_client.ping()) # True
Pro tip: Use
decode_responses=Trueso Redis returns Python strings instead of bytes—this simplifies working with JSON payloads.
Step 2: Create a helper to build cache keys
A good cache key is deterministic and includes everything that changes the response. For a product endpoint, that's the product ID and any query parameters.
# helpers.py
import json
def make_cache_key(endpoint: str, **params) -> str:
# Sort params to ensure consistent ordering
sorted_params = json.dumps(params, sort_keys=True, default=str)
return f"{endpoint}:{sorted_params}"
# Example usage
key = make_cache_key("product", id=42, lang="en")
print(key) # product:{"id": 42, "lang": "en"}
Step 3: Add a caching decorator to your route
Now for the magic. This decorator wraps any synchronous or asynchronous endpoint and caches its result.
# decorators.py
import functools
import json
from fastapi import HTTPException
from .cache import redis_client
def cache_response(ttl: int = 60):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
# Build a key from the function name and arguments
key_parts = [func.__name__] + [str(arg) for arg in args] + \
[f"{k}={v}" for k, v in kwargs.items()]
key = ":".join(key_parts)
# Try cache hit
cached = redis_client.get(key)
if cached is not None:
return json.loads(cached)
# Cache miss: call the actual endpoint
response = await func(*args, **kwargs)
# Store in Redis as JSON (serialize dict or Pydantic model)
if hasattr(response, "model_dump"):
data = response.model_dump()
else:
data = response
redis_client.setex(key, ttl, json.dumps(data, default=str))
return response
return wrapper
return decorator
Step 4: Use it on a real endpoint
# main.py
from fastapi import FastAPI
from .decorators import cache_response
app = FastAPI()
# Pretend this is a slow database query
async def get_product_from_db(product_id: int):
import asyncio
await asyncio.sleep(2) # Simulate latency
return {"id": product_id, "name": "Laptop", "price": 999}
@app.get("/products/{product_id}")
@cache_response(ttl=30)
async def get_product(product_id: int):
# This line logs only on cache miss
print("Computing directly...")
return await get_product_from_db(product_id)
Expected output (run with uvicorn main:app --reload):
- First request to
http://localhost:8000/products/1takes ~2 seconds and prints "Computing directly...". - Subsequent requests within 30 seconds return instantly (milliseconds) and print nothing.
- After 30 seconds, the first request again prints the message and refetches.
Note: the decorator order matters—@app.get must be above @cache_response, so the route registration captures the wrapper, not the original function.
Compare options / when to choose what
You have a few ways to cache in FastAPI. Redis is not your only option, but it shines in production. Let's compare the common choices.
| Approach | Setup | Performance | Persistence | Use case |
|---|---|---|---|---|
In-memory dict (e.g., cachetools) |
None | Very fast | Lost on restart | Single-process dev or tiny internal services |
Redis (via redis-py) |
Separate server/container | Fast (network round-trip, but <1ms) | Configurable (RDB/AOF) | Multi-instance APIs, shared caches, TTL support |
FastAPI built-in lru_cache |
Built-in, no deps | Extremely fast | In-process, lost on restart | Pure functions with no side effects |
| Memcached | Separate server | Similar to Redis | No persistence | Simple key-value caching without data structures |
When to choose Redis: you have multiple API servers that need a shared cache, you want TTLs and eviction policies, or you plan to use the same Redis for other tasks (rate limiting, queues, sessions). For a single-process toy project, an in-memory dict is simpler, but you'll quickly outgrow it in production.
Variation 1: Use @lru_cache for pure functions that don't depend on external mutable state. It's a zero-dependency way to memoize results, but it has no TTL and can consume memory forever.
Variation 2: For a more declarative approach, use a library like fastapi-cache or fastapi-cache2, which integrates Redis or in-memory backends behind a simple decorator (@cache(expire=60)). It's great for rapid prototyping but adds a dependency.
Variation 3: For advanced needs, implement a custom Cache class that wraps Redis with namespacing, versioning, and automatic invalidations—this gives you fine-grained control over your caching strategy.
Troubleshooting & edge cases
Even with a simple decorator, things can go wrong. Here are the most common issues and how to fix them.
Connection errors (ConnectionError: Error while reading from socket)
- Cause: Redis isn't running or is unreachable.
- Fix: Start Redis with
redis-serveror use Docker (docker run -p 6379:6379 redis). Check the host and port in your connection string.
Serialization issues (e.g., TypeError: Object of type Product is not JSON serializable)
- Cause: You're trying to
json.dumpsa Pydantic model directly. - Fix: Use
model_dump()(or.dict()for older Pydantic) to get a dict first, as shown in the decorator.
Cached data goes stale too fast (or too slow)
- Cause: TTL is too short for your data, or too long and you're serving outdated info.
- Fix: Choose TTL based on how often the underlying data changes. For product prices that update hourly, a 300-second TTL is fine; for a news feed, maybe 10 seconds.
Multiple instances returning different caches
- Cause: Each server has its own in-memory cache.
- Fix: Use Redis as a shared cache so all instances see the same data.
Cached responses bypass authentication
- Cause: You cached a response that should differ per user.
- Fix: Include user IDs or roles in the cache key—or don't cache at all for user-specific endpoints.
@cache_response decorator order error
- Cause: Putting
@cache_responseabove@app.get. - Fix: Always place
@app.get(or@app.post) on top, so FastAPI registers the wrapped function.
What you learned & what's next
You've just unlocked a major performance lever. You now understand the core concept behind caching responses with Redis and FastAPI, and you've completed a hands-on exercise that adds a Redis-backed cache to real endpoints. You can explain why caching solves the repeated-work problem, and you can choose between in-memory, Redis, and built-in tools based on your needs. You also know how to troubleshoot the most common pitfalls, from connection errors to stale data.
What's next in your FastAPI track: you'll likely explore rate limiting or background tasks. Both follow the same Redis-backed pattern. You could even combine them—for example, caching a rate-limited endpoint so you don't hit the database on every rejected request.
Keep building, and remember: cache as much as you can, but always set a sensible TTL.
Practice recap
Now try it yourself: extend the example by adding a lang query parameter to the product endpoint and include it in the cache key. Then run two parallel requests with different languages and verify each gets its own cached response. For an extra challenge, simulate a database update and manually invalidate the cache by deleting the key from Redis.
Common mistakes
- Forgetting to set
decode_responses=True, which returns bytes instead of strings and breaks JSON parsing. - Caching user-specific data without including user ID or role in the cache key, leaking one user's data to another.
- Using a TTL that's too long for frequently changing data, serving stale responses that frustrate users.
- Placing
@cache_responseabove@app.get, causing FastAPI to register the original function instead of the cached wrapper. - Trying to serialize a Pydantic model directly into JSON without calling
.model_dump()first.
Variations
- Use the
fastapi-cachelibrary for a declarative@cache(expire=60)decorator that supports Redis and in-memory backends. - Combine Redis with
lru_cachefor computationally intensive pure functions, keeping Redis for shared API responses. - Implement a custom Cache class with namespaces and invalidation for fine-grained control in complex services.
Real-world use cases
- Caching product detail responses for an e-commerce API to reduce database load during flash sales.
- Caching third-party API responses for weather or currency data with a 5-minute TTL to lower external costs and latency.
- Caching a list of featured articles on a news API endpoint, refreshed every hour, to handle traffic spikes.
Key takeaways
- Caching responses with Redis and FastAPI uses the cache-aside pattern: check Redis, compute and store on miss, return on hit.
- Build deterministic cache keys from endpoint name and all parameters that affect the response.
- Always set a time-to-live (TTL) to prevent stale data and automatic cleanup.
- Serialize Pydantic models using
model_dump()before storing them as JSON in Redis. - Choose Redis over in-memory caches when you have multiple server instances or need persistence and advanced eviction policies.
- Test your cache with a simulated slow endpoint to verify hits return instantly and TTL expiration triggers a refetch.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.