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.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_page caches the full HTTP response including headers. Use vary_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 rate
  • evicted_keys → indicates memory pressure
  • expired_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']}")

🔗 See: Monitoring and Observability for Python APIs

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) or redis.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

STAY IN TOUCH

Get notified when I publish something new, and unsubscribe at any time.