Redis Caching in Django and FastAPI: A Practical Guide
Introduction
One of the easiest ways to dramatically improve your API's performance is caching. When an endpoint queries the database for the same data on every request, your database becomes a bottleneck — even if it's well-optimized. Redis caching lets you store the result of an expensive operation once and serve it instantly to subsequent requests.
In this guide, we'll implement Redis caching in both Django and FastAPI — from basic setup to cache invalidation strategies, production best practices, and monitoring.
🔗 Related: Rate Limiting and Throttling in Django and FastAPI — Redis is also the recommended backing store for production rate limiting.
Why Caching Matters for API Performance
Consider a product listing endpoint that queries multiple joined tables:
# This runs a complex query on every single request
def get_products():
return Product.objects.select_related("category").prefetch_related("tags").all()
Without caching, this query runs 100 times for 100 concurrent requests. With caching, it runs once and the result is served from memory for the remaining 99. The difference in response time is typically:
- Without cache: 150–500ms (depending on query complexity)
- With cache hit: 1–5ms
That's a 50–100x improvement for repeated reads.
When to Cache
- Read-heavy, write-light endpoints: product listings, user profiles, blog posts
- Expensive aggregations: analytics dashboards, reporting endpoints
- Rarely-changing reference data: categories, countries, configuration
- External API responses: third-party data you don't control
When Not to Cache
- Real-time data that must always be fresh (live scores, stock prices)
- User-specific sensitive data (without careful per-user key design)
- Endpoints where stale data would cause bugs
Redis Fundamentals: Data Structures You Need to Know
Redis is an in-memory key-value store with rich data structure support. For API caching, you'll primarily use:
Strings (most common)
SET cache:products:all '{"data": [...]}' EX 300 # store with 5-minute TTL
GET cache:products:all # retrieve
DEL cache:products:all # delete
TTL cache:products:all # check remaining TTL
Hashes (for structured objects)
HSET user:123 name "Alice" email "alice@example.com" role "admin"
HGETALL user:123
HGET user:123 name
TTL (Time-To-Live)
Every cache entry should have a TTL. When it expires, Redis automatically deletes the key, ensuring stale data doesn't linger forever:
SET key value EX 300 # expire in 300 seconds
SET key value PX 5000 # expire in 5000 milliseconds
EXPIRE key 300 # set TTL on existing key
PERSIST key # remove TTL (make key permanent)
Setting Up Redis Locally and in Docker
macOS
brew install redis
brew services start redis
redis-cli ping # → PONG
Ubuntu/Debian
sudo apt-get update && sudo apt-get install redis-server
sudo systemctl enable --now redis-server
Docker Compose (Recommended for Development)
# docker-compose.yml
version: "3.9"
services:
app:
build: .
ports:
- "8000:8000"
environment:
- REDIS_URL=redis://redis:6379/0
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
- redis_data:/data
volumes:
redis_data:
🐳 Related: Dockerizing Django and FastAPI Applications
Django Caching: The Built-in Cache Framework with Redis Backend
Django ships with a powerful caching framework that supports multiple backends. To use Redis, we install django-redis:
Installation
pip install django-redis
# or with uv:
uv add django-redis
Configuration
# settings.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
"RETRY_ON_TIMEOUT": True,
"MAX_CONNECTIONS": 100,
},
"KEY_PREFIX": "myapp", # prevents collisions with other apps
"TIMEOUT": 300, # default TTL: 5 minutes
}
}
Basic Cache API
from django.core.cache import cache
# Store a value
cache.set("key", {"data": [1, 2, 3]}, timeout=300)
# Retrieve it (returns None on cache miss)
value = cache.get("key")
# Get or compute (atomic: only one thread computes on miss)
value = cache.get_or_set("key", lambda: expensive_computation(), timeout=300)
# Delete
cache.delete("key")
# Check if a key exists
cache.has_key("key")
# Set multiple values at once
cache.set_many({"key1": "val1", "key2": "val2"}, timeout=300)
# Get multiple values at once
values = cache.get_many(["key1", "key2"])
Caching Views with cache_page
The quickest way to cache a Django view:
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from rest_framework.decorators import api_view
@cache_page(60 * 15) # cache for 15 minutes
@api_view(["GET"])
def product_list(request):
products = Product.objects.select_related("category").all()
serializer = ProductSerializer(products, many=True)
return Response(serializer.data)
For class-based views:
from django.utils.decorators import method_decorator
from rest_framework.views import APIView
@method_decorator(cache_page(60 * 15), name="dispatch")
class ProductListView(APIView):
def get(self, request):
products = Product.objects.all()
serializer = ProductSerializer(products, many=True)
return Response(serializer.data)
⚠️
cache_pagecaches the full HTTP response including headers. Usevary_on_headers("Authorization")if authenticated users should get different responses.
Caching Django REST Framework Views and Querysets
For more control, cache querysets directly rather than entire responses:
from django.core.cache import cache
class ProductViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ProductSerializer
def get_queryset(self):
cache_key = "products:all:v1"
queryset = cache.get(cache_key)
if queryset is None:
queryset = list(
Product.objects.select_related("category")
.prefetch_related("tags")
.filter(is_active=True)
.order_by("-created_at")
)
cache.set(cache_key, queryset, timeout=600)
return queryset
Note: Serialize the queryset to a list before caching — Django querysets are lazy and won't serialize to the cache as-is.
🔗 Caching querysets works well alongside query optimization. See: How to Prevent N+1 Query Problems in Django and FastAPI
FastAPI Caching with redis-py and aioredis
FastAPI is async-first, so we use an async Redis client. redis-py v4+ ships with an async interface:
Installation
uv add "redis[hiredis]"
Setup and Dependency Injection
# app/core/cache.py
import redis.asyncio as aioredis
from functools import lru_cache
from app.core.config import settings
@lru_cache()
def get_redis_pool() -> aioredis.ConnectionPool:
return aioredis.ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=20,
decode_responses=True,
)
async def get_redis() -> aioredis.Redis:
return aioredis.Redis(connection_pool=get_redis_pool())
# app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
CACHE_DEFAULT_TTL: int = 300
class Config:
env_file = ".env"
settings = Settings()
Basic Endpoint Caching
import json
import redis.asyncio as aioredis
from fastapi import Depends, FastAPI
from app.core.cache import get_redis
app = FastAPI()
@app.get("/api/products")
async def list_products(redis: aioredis.Redis = Depends(get_redis)):
cache_key = "products:list"
# Check cache
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss — query database
products = await fetch_products_from_db() # your DB call here
# Store result in cache
await redis.setex(cache_key, 300, json.dumps(products))
return products
Building a Reusable Cache Decorator for FastAPI
Manually writing the cache-check pattern in every endpoint gets repetitive. A decorator centralizes the logic:
# app/utils/cache.py
import json
import hashlib
import functools
from typing import Callable, Optional
import redis.asyncio as aioredis
from fastapi import Request
from app.core.cache import get_redis_pool
def cache_response(ttl: int = 300, key_prefix: str = ""):
"""
Decorator that caches the return value of an async FastAPI route handler.
The cache key is built from the prefix and the request URL + query params.
"""
def decorator(func: Callable):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
# Find the Request object in arguments
request: Optional[Request] = kwargs.get("request")
if request is None:
for arg in args:
if isinstance(arg, Request):
request = arg
break
# Build a deterministic cache key
if request:
raw_key = f"{key_prefix}:{request.url.path}:{request.query_params}"
cache_key = hashlib.sha256(raw_key.encode()).hexdigest()[:32]
else:
cache_key = f"{key_prefix}:{func.__name__}"
redis = aioredis.Redis(connection_pool=get_redis_pool())
# Try cache
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# Call handler
result = await func(*args, **kwargs)
# Cache result
await redis.setex(cache_key, ttl, json.dumps(result))
return result
return wrapper
return decorator
Usage:
from app.utils.cache import cache_response
@app.get("/api/products")
@cache_response(ttl=300, key_prefix="products")
async def list_products(request: Request):
return await fetch_products_from_db()
@app.get("/api/categories")
@cache_response(ttl=3600, key_prefix="categories") # categories change rarely
async def list_categories(request: Request):
return await fetch_categories_from_db()
Cache Invalidation Strategies
Cache invalidation is famously hard. Here are three practical strategies:
1. TTL-Based Expiration (Simplest)
Set a time-to-live and accept that data may be stale for up to that duration.
# Redis automatically deletes the key after TTL seconds
await redis.setex("products:all", 300, json.dumps(data))
Best for: Public data where slight staleness is acceptable (blog posts, product listings).
2. Event-Based Invalidation (Most Accurate)
Delete or update the cache entry when the source data changes.
Django — use model signals:
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from django_redis import get_redis_connection
@receiver(post_save, sender=Product)
@receiver(post_delete, sender=Product)
def invalidate_product_caches(sender, instance, **kwargs):
# Delete specific product cache
cache.delete(f"product:{instance.pk}")
# Delete list caches (use pattern matching)
con = get_redis_connection("default")
keys = con.keys("myapp:products:*")
if keys:
con.delete(*keys)
FastAPI — invalidate in mutation endpoints:
@app.put("/api/products/{product_id}")
async def update_product(
product_id: int,
data: ProductUpdateSchema,
redis: aioredis.Redis = Depends(get_redis),
):
updated = await db_update_product(product_id, data)
# Invalidate affected caches
await redis.delete(f"product:{product_id}")
await redis.delete("products:list")
return updated
3. Versioned Cache Keys
Bump a version counter to invalidate groups of keys without scanning:
async def get_cache_version(redis: aioredis.Redis, namespace: str) -> int:
version = await redis.get(f"version:{namespace}")
return int(version) if version else 1
async def invalidate_namespace(redis: aioredis.Redis, namespace: str):
await redis.incr(f"version:{namespace}") # atomic
@app.get("/api/products")
async def list_products(redis: aioredis.Redis = Depends(get_redis)):
version = await get_cache_version(redis, "products")
cache_key = f"products:list:v{version}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
data = await fetch_products_from_db()
await redis.setex(cache_key, 600, json.dumps(data))
return data
@app.post("/api/products")
async def create_product(
data: ProductCreateSchema,
redis: aioredis.Redis = Depends(get_redis),
):
product = await db_create_product(data)
await invalidate_namespace(redis, "products") # all product caches stale
return product
Caching Patterns: Cache-Aside, Read-Through, Write-Through
Cache-Aside (Lazy Loading)
The most common pattern. The application manages the cache explicitly:
Request → Check cache
Hit? → Return cached data
Miss? → Query DB → Store in cache → Return data
Read-Through
The cache layer automatically fetches from the database on a miss. Useful with libraries like aiocache:
from aiocache import cached, Cache
from aiocache.serializers import JsonSerializer
@cached(ttl=300, cache=Cache.REDIS, serializer=JsonSerializer(), key="products:all")
async def get_all_products():
return await fetch_products_from_db()
Write-Through
Write to both the cache and the database simultaneously. Ensures the cache is never stale after a write:
async def update_product(product_id: int, data: dict, redis: aioredis.Redis):
# Write to database
updated = await db_update_product(product_id, data)
# Write to cache simultaneously
await redis.setex(f"product:{product_id}", 600, json.dumps(updated))
return updated
Monitoring Redis: Memory Usage, Hit Rates, Eviction Policies
Key Metrics to Watch
redis-cli INFO stats
Look for:
keyspace_hits/keyspace_misses→ calculate hit rateevicted_keys→ indicates memory pressureexpired_keys→ keys removed by TTL
Hit Rate Formula
hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses) * 100
Aim for >90% hit rate. Lower rates usually mean:
- TTLs are too short
- Cache keys are too granular (over-invalidating)
- You're caching the wrong data
import redis
r = redis.Redis()
info = r.info("stats")
total = info["keyspace_hits"] + info["keyspace_misses"]
if total > 0:
hit_rate = (info["keyspace_hits"] / total) * 100
print(f"Hit rate: {hit_rate:.1f}%")
print(f"Evictions: {info['evicted_keys']}")
Eviction Policy
Set a memory limit and eviction policy so Redis doesn't crash when memory fills up:
redis-cli CONFIG SET maxmemory 512mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
Recommended policies for a cache:
allkeys-lru— evict least recently used keys first (most common)allkeys-lfu— evict least frequently used keys (better for uneven access patterns)volatile-lru— only evict keys with a TTL set (keeps permanent keys safe)
Production Best Practices: Clustering, Persistence, Security
Always Use Connection Pooling
Never create a new Redis connection per request:
# ✅ Good — module-level pool reused across all requests
from functools import lru_cache
@lru_cache()
def get_redis_pool():
return aioredis.ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=50,
)
# ❌ Bad — new connection on every request
async def get_products(request):
redis = await aioredis.from_url("redis://localhost:6379")
...
🔗 Related: Database Connection Pooling in FastAPI with SQLAlchemy
Redis Sentinel for High Availability
For production deployments that need automatic failover:
# Django settings.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": [
"redis://sentinel-1:26379/1",
"redis://sentinel-2:26379/1",
"redis://sentinel-3:26379/1",
],
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.SentinelClient",
"SENTINEL_SERVICE_NAME": "mymaster",
},
}
}
Security Hardening
# redis.conf
# 1. Require a password
requirepass your-strong-redis-password
# 2. Bind to private interface only
bind 127.0.0.1 10.0.0.5
# 3. Disable dangerous commands
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command DEBUG ""
# 4. Enable TLS for connections over the network
tls-port 6380
tls-cert-file /etc/redis/redis.crt
tls-key-file /etc/redis/redis.key
In your application, always use environment variables for the Redis connection string:
# .env
REDIS_URL=redis://:your-strong-redis-password@redis-host:6379/0
# Never hardcode credentials in source code
Data Persistence
For a pure cache (data you can afford to lose), persistence is optional. For session data or other important cache data, enable AOF:
# redis.conf
appendonly yes
appendfsync everysec # fsync every second (good balance)
Conclusion
Redis caching is one of the most effective ways to improve API performance and reduce database load. The key takeaways:
- Start simple: TTL-based caching with
cache.get_or_set()(Django) orredis.get/setex(FastAPI) solves most problems - Invalidate on change: For data your application writes, invalidate caches in signals (Django) or mutation endpoints (FastAPI)
- Use connection pools: Always reuse connections — never create one per request
- Monitor hit rates: Target >90%; anything lower suggests a caching strategy problem
- Set memory limits and eviction policies in production so Redis never runs out of memory silently
Both Django's built-in cache framework and FastAPI's async Redis integration are mature and production-proven. Pick the approach that fits your stack and start caching your most expensive endpoints today.
📌 Up next: Building MCP Servers with Python: The Complete Guide for 2026