DEV Community

Cover image for Redis caching in FastAPI: practical setup and pitfalls
Ayush Kumar
Ayush Kumar

Posted on • Originally published at logiclooptech.dev

Redis caching in FastAPI: practical setup and pitfalls

Quick answer:

If you want fast, cheap look-ups for query results, session data, or computed values, add a Redis instance, connect with aioredis, wrap your endpoints in a small decorator, and let the cache expire or be cleared on writes. The whole stack fits in a few dozen lines and costs pennies per month on most cloud providers.

Below I walk through a production-ready implementation of redis caching in fastapi. I’ll show you how to install Redis, wire an async client, build reusable decorators, handle invalidation, spin the service up with Docker, and keep an eye on performance once you’re in the wild. I’ll also point out the places where things tend to break and what I would do differently next time.


Installing and configuring Redis for a FastAPI project

Do I need a separate Redis server?

Yes. Even a single-node instance gives you in-memory speed without adding latency to your API process. For local development you can run the official Docker image; for production you’ll want a managed service or a replicated cluster.

Local Docker setup

# docker-compose.yml
version: "3.9"
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

Run docker-compose up -d redis. The container is tiny (≈30 MB) and starts in a second. On a VM or Kubernetes you would expose the service via a ClusterIP or a cloud-managed endpoint.

Production connection string

# .env
REDIS_URL=redis://:myStrongPassword@redis-prod.internal:6379/0
Enter fullscreen mode Exit fullscreen mode

Never hard-code credentials. Keep the URL in a secret manager and inject it at runtime. I’ve been bitten by missing redis:// prefixes when moving from localhost to a managed instance - FastAPI will raise a ValueError at startup.


How do I integrate an async Redis client with aioredis?

Can I use the same client for all requests?

Yes, but you should create a single connection pool at startup and share it via FastAPI’s dependency system. aioredis (now part of redis-py v4) gives you native asyncio support.

# app/redis.py
import os
from redis.asyncio import Redis
from fastapi import FastAPI

def get_redis_pool() -> Redis:
    return Redis.from_url(os.getenv("REDIS_URL"), decode_responses=True)

def add_redis(app: FastAPI) -> None:
    @app.on_event("startup")
    async def startup_redis():
        app.state.redis = get_redis_pool()
        # Test the connection early
        await app.state.redis.ping()

    @app.on_event("shutdown")
    async def shutdown_redis():
        await app.state.redis.close()
Enter fullscreen mode Exit fullscreen mode

In any route you can now do:

from fastapi import Depends, Request

async def redis_dep(request: Request) -> Redis:
    return request.app.state.redis
Enter fullscreen mode Exit fullscreen mode

Trade-offs

  • Single pool – reduces connection overhead but can become a bottleneck if you saturate the event loop with heavy Redis work. In that case, spawn a second pool for low-latency reads.
  • Blocking commands – avoid commands that return huge payloads; they block the event loop while the client decompresses data. Use SCAN instead of KEYS in production.

How can I build reusable cache decorators for FastAPI endpoints?

Is a decorator enough for all use cases?

For read-only or idempotent endpoints, a decorator is clean and keeps the view logic tidy. For write-heavy resources you’ll need explicit invalidation.

# app/cache.py
import functools
import json
from typing import Callable, Any
from redis.asyncio import Redis

def redis_cache(key_builder: Callable[..., str],
                ttl: int = 300):
    """Cache the result of an async endpoint.
    key_builder receives the same args/kwargs as the endpoint.
    """
    def decorator(func: Callable):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            request = kwargs.get("request")
            redis: Redis = request.app.state.redis  # type: ignore
            cache_key = key_builder(*args, **kwargs)
            cached = await redis.get(cache_key)
            if cached is not None:
                return json.loads(cached)

            result = await func(*args, **kwargs)
            await redis.set(cache_key, json.dumps(result), ex=ttl)
            return result
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

Example usage

# app/routers/items.py
from fastapi import APIRouter, Request
from .cache import redis_cache

router = APIRouter()

def item_key(request: Request, item_id: int):
    return f"item:{item_id}"

@router.get("/items/{item_id}")
@redis_cache(key_builder=item_key, ttl=600)
async def get_item(request: Request, item_id: int):
    # Simulate expensive DB call
    return {"id": item_id, "value": "expensive computation"}
Enter fullscreen mode Exit fullscreen mode

The decorator automatically reuses the pool created earlier, serializes the response with json, and respects the TTL.

When not to use it

  • Streaming responses – you cannot cache a generator without buffering the whole payload.
  • Very large payloads – they waste memory and network bandwidth. Consider pagination or a separate blob store.

What are the best cache invalidation and expiration strategies?

How do I keep stale data from leaking?

  1. Time-based expiration (TTL) – simplest. Set a reasonable ttl per endpoint. For data that changes every few minutes, a 60-second TTL is enough.
  2. Write-through invalidation – after you write to the database, delete the related keys.
# app/routers/items.py
@router.put("/items/{item_id}")
async def update_item(request: Request, item_id: int, payload: dict):
    # Update DB here …
    await request.app.state.redis.delete(f"item:{item_id}")
    return {"status": "updated"}
Enter fullscreen mode Exit fullscreen mode
  1. Tag-based eviction – store a set of keys per logical group and delete the whole set on a bulk change.
# add tag on set
await redis.sadd(f"tag:user:{user_id}", cache_key)
# later invalidate all
keys = await redis.smembers(f"tag:user:{user_id}")
if keys:
    await redis.delete(*keys)
    await redis.delete(f"tag:user:{user_id}")
Enter fullscreen mode Exit fullscreen mode

Pitfalls

  • Forgotten invalidation – I once added a new PUT endpoint and missed the delete call. The cache kept returning the old version for hours. Adding a unit test that checks cache freshness saved me a lot of tickets.
  • Over-eager expiration – setting TTLs too low defeats the purpose of caching and adds load to Redis. Profile your read/write ratio before deciding.

How do I deploy Redis with Docker and handle environment-specific settings?

Can I keep the same compose file for dev and prod?

Yes, but you should separate the image version and resource limits. In production you may want persistence, ACLs, and monitoring side-cars.

Production-ready compose snippet

# docker-compose.prod.yml
version: "3.9"
services:
  redis:
    image: redis:7-alpine
    command:
      - redis-server
      - /usr/local/etc/redis/redis.conf
    volumes:
      - redis-data:/data
      - ./redis.conf:/usr/local/etc/redis/redis.conf:ro
    environment:
      - REDIS_PASSWORD=${REDIS_PASSWORD}
    ports:
      - "6379:6379"
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M
    restart: unless-stopped

volumes:
  redis-data:
Enter fullscreen mode Exit fullscreen mode

redis.conf can enable ACLs:

requirepass ${REDIS_PASSWORD}
maxmemory 256mb
maxmemory-policy allkeys-lru
Enter fullscreen mode Exit fullscreen mode

When deploying to Google Cloud Run you cannot run a side-car, so you’ll point the FastAPI container to a managed Memorystore instance instead. The same REDIS_URL env var works for both setups.

What I’d do differently

  • Separate config repo – keep redis.conf in a dedicated folder, version it, and inject secrets via the orchestrator. I once edited the file inside the container and lost the change after a redeploy.
  • Health checks – add a simple redis-cli ping health endpoint in your orchestrator. Without it, a pod could start while Redis was still booting, causing a cascade of connection errors.

How can I monitor and troubleshoot Redis cache performance in production?

What metrics matter most?

Hit-rate, latency, memory usage, and evicted keys. Redis ships with INFO and the Redis Exporter for Prometheus.

Prometheus exporter

# docker-compose.yml (add beside redis)
redis-exporter:
  image: oliver006/redis_exporter
  environment:
    - REDIS_ADDR=redis://:${REDIS_PASSWORD}@redis:6379
  ports:
    - "9121:9121"
Enter fullscreen mode Exit fullscreen mode

Grafana dashboards can then show redis_keyspace_hits_total / (hits + misses) as a percentage. A dip below 80 % usually means your TTL is too short or you’re missing a cache-warming step.

Logging slow commands

Add to redis.conf:

slowlog-log-slower-than 10000   # microseconds
slowlog-max-len 128
Enter fullscreen mode Exit fullscreen mode

You can query SLOWLOG GET from your FastAPI admin endpoint to see which queries are eating time. In one project I discovered a ZRANGEBYSCORE on a 10 M-element sorted set that took 250 ms; the fix was to add a secondary index.

Common failure modes

Symptom Likely cause Quick fix
ConnectionError: Timeout Redis container restarted or network partition Check Docker logs, add restart: always, verify health probe
Cache always miss Wrong key format or missing decode_responses=True Ensure the same key builder is used for set and get
Memory limit reached maxmemory too low, or unbounded keys Enable LRU eviction, monitor used_memory_peak

FAQ

How many Redis nodes do I need for a small FastAPI service?

A single node is fine for low-traffic workloads. Add a replica once you exceed ~100 K requests per second or need high availability.

Can I use Redis as a message broker instead of a cache?

Technically yes, but Pub/Sub semantics differ from a durable queue. For background jobs I prefer RabbitMQ or Google Pub/Sub.

Is it safe to store user-session data in Redis without encryption?

If the data is not personally identifiable, plain storage is okay. For anything sensitive, enable TLS on the Redis endpoint and store only a session token that references server-side data.

What’s the cost difference between managed Memorystore and a self-hosted Docker container?

Managed services start at a few dollars per GB per month and include automatic failover. A self-hosted container on a small VM can be cheaper (< $5) but you’re responsible for backups and scaling.


Key Takeaways

  • Install Redis as a separate service; use Docker locally and a managed instance in prod.
  • Create a single async Redis pool at FastAPI startup and inject it via request state.
  • Wrap read-only endpoints in a lightweight decorator that builds keys, checks the cache, and sets a TTL.
  • Invalidate on writes with explicit delete calls or tag-based sets for bulk eviction.
  • Deploy with environment-specific redis.conf, enable ACLs, and set sensible memory limits.
  • Export metrics to Prometheus, watch hit-rate and latency, and enable the slow-log for troubleshooting.

With these pieces in place, redis caching in fastapi becomes a low-maintenance performance boost that scales from a laptop dev box to a multi-region production fleet. Happy caching!

Top comments (0)