Cache Database Query Results with Redis and Python
Cache expensive database query results in Redis with cache-aside pattern, TTL management, and invalidation on writes for Python applications.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Database query result caching stores the result of expensive queries in Redis so subsequent requests skip the database entirely. The cache-aside pattern — check cache, fetch from DB on miss, populate cache — is the most common approach. Below: implementing cache-aside in Python with Redis, handling serialization, invalidation on writes, cache stampede prevention, and multi-query caching.
When to Use This
- Expensive queries (aggregations, joins, full-text search) that run frequently
- Read-heavy workloads where data changes infrequently
- Reducing database load during traffic spikes
- Dashboard or reporting queries with acceptable staleness
Prerequisites
- Python 3.10+
- Redis server
redis(redis-py) andsqlalchemypackages
Solution
1. Install Dependencies
pip install redis sqlalchemy
2. Cache-Aside Pattern
import json
import redis
from sqlalchemy import create_engine, text
engine = create_engine("postgresql://user:pass@localhost/mydb")
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
def get_product(product_id: int) -> dict:
cache_key = f"product:{product_id}"
# 1. Check cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 2. Fetch from database on miss
with engine.connect() as conn:
result = conn.execute(
text("SELECT id, name, price FROM products WHERE id = :id"),
{"id": product_id}
)
row = result.fetchone()
if row is None:
return None
product = {"id": row.id, "name": row.name, "price": float(row.price)}
# 3. Populate cache with TTL
redis_client.setex(cache_key, 300, json.dumps(product))
return product
3. Cache Invalidation on Writes
def update_product(product_id: int, name: str, price: float) -> dict:
with engine.begin() as conn:
conn.execute(
text("UPDATE products SET name = :name, price = :price WHERE id = :id"),
{"id": product_id, "name": name, "price": price}
)
# Invalidate cache — next read will fetch fresh data
redis_client.delete(f"product:{product_id}")
return {"id": product_id, "name": name, "price": price}
def delete_product(product_id: int) -> None:
with engine.begin() as conn:
conn.execute(
text("DELETE FROM products WHERE id = :id"),
{"id": product_id}
)
redis_client.delete(f"product:{product_id}")
4. Caching List Queries
def get_products_by_category(category_id: int, page: int = 1, per_page: int = 20) -> list:
cache_key = f"products:category:{category_id}:page:{page}:size:{per_page}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
offset = (page - 1) * per_page
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT id, name, price FROM products
WHERE category_id = :cat
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
"""),
{"cat": category_id, "limit": per_page, "offset": offset}
)
products = [
{"id": row.id, "name": row.name, "price": float(row.price)}
for row in result
]
redis_client.setex(cache_key, 300, json.dumps(products))
return products
def invalidate_category_cache(category_id: int) -> None:
# Delete all paginated cache entries for this category
pattern = f"products:category:{category_id}:*"
keys = list(redis_client.scan_iter(match=pattern, count=100))
if keys:
redis_client.delete(*keys)
5. Cache Stampede Prevention with Lock
import time
import uuid
def get_product_with_lock(product_id: int) -> dict:
cache_key = f"product:{product_id}"
lock_key = f"lock:{cache_key}"
# Check cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Try to acquire lock
lock_token = str(uuid.uuid4())
lock_acquired = redis_client.set(lock_key, lock_token, nx=True, ex=10)
if lock_acquired:
try:
# Fetch from DB
product = fetch_product_from_db(product_id)
if product:
redis_client.setex(cache_key, 300, json.dumps(product))
return product
finally:
# Release lock (only if we still own it)
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
redis_client.eval(lua_script, 1, lock_key, lock_token)
else:
# Wait and retry
time.sleep(0.1)
return get_product_with_lock(product_id)
6. Caching Aggregation Queries
def get_sales_summary(start_date: str, end_date: str) -> dict:
cache_key = f"sales:summary:{start_date}:{end_date}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
COUNT(*) as total_orders,
SUM(total) as revenue,
AVG(total) as avg_order_value
FROM orders
WHERE created_at BETWEEN :start AND :end
"""),
{"start": start_date, "end": end_date}
)
row = result.fetchone()
summary = {
"total_orders": row.total_orders,
"revenue": float(row.revenue) if row.revenue else 0,
"avg_order_value": float(row.avg_order_value) if row.avg_order_value else 0,
}
# Cache for 5 minutes — aggregations don't need real-time freshness
redis_client.setex(cache_key, 300, json.dumps(summary))
return summary
7. Write-Through Cache
def create_product_write_through(name: str, price: float, category_id: int) -> dict:
with engine.begin() as conn:
result = conn.execute(
text("""
INSERT INTO products (name, price, category_id)
VALUES (:name, :price, :cat)
RETURNING id, name, price
"""),
{"name": name, "price": price, "cat": category_id}
)
row = result.fetchone()
product = {"id": row.id, "name": row.name, "price": float(row.price)}
# Populate cache immediately — no stale window
redis_client.setex(f"product:{row.id}", 300, json.dumps(product))
# Invalidate list caches that would include this product
invalidate_category_cache(category_id)
return product
8. Batch Cache Loading
def get_products_batch(product_ids: list) -> dict:
# Use MGET for batch cache lookup
cache_keys = [f"product:{pid}" for pid in product_ids]
cached_values = redis_client.mget(cache_keys)
results = {}
missing_ids = []
for pid, cached in zip(product_ids, cached_values):
if cached:
results[pid] = json.loads(cached)
else:
missing_ids.append(pid)
# Fetch missing from DB in a single query
if missing_ids:
with engine.connect() as conn:
placeholders = ",".join(f":id{i}" for i in range(len(missing_ids)))
params = {f"id{i}": pid for i, pid in enumerate(missing_ids)}
result = conn.execute(
text(f"SELECT id, name, price FROM products WHERE id IN ({placeholders})"),
params
)
for row in result:
product = {"id": row.id, "name": row.name, "price": float(row.price)}
results[row.id] = product
redis_client.setex(f"product:{row.id}", 300, json.dumps(product))
return results
How It Works
- Cache-aside: The application checks the cache before querying the database. On a cache hit, the cached value is returned. On a miss, the database is queried, and the result is stored in the cache with a TTL.
- Invalidation: When data changes (INSERT, UPDATE, DELETE), the application explicitly deletes the corresponding cache key. The next read fetches fresh data from the database and repopulates the cache.
- Cache stampede: When a popular cache entry expires, many concurrent requests simultaneously hit the database. A Redis lock (
SET NX EX) ensures only one request fetches from the database while others wait. - Batch loading:
MGETfetches multiple cache entries in a single Redis command. Missing entries are fetched from the database in a singleWHERE id IN (...)query. - TTL as safety net: Even with explicit invalidation, a TTL ensures stale data self-heals if an invalidation is missed (e.g., due to a bug or exception).
Variants
Cache with TTL Jitter
import random
def set_with_jitter(key: str, value: str, base_ttl: int = 300):
jitter = random.randint(0, 60)
redis_client.setex(key, base_ttl + jitter, value)
Read-Through Cache (Transparent)
class ReadThroughCache:
def __init__(self, redis_client, db_fetch_fn, ttl=300):
self.redis = redis_client
self.fetch_fn = db_fetch_fn
self.ttl = ttl
def get(self, key: str, *args, **kwargs):
cached = self.redis.get(key)
if cached:
return json.loads(cached)
value = self.fetch_fn(*args, **kwargs)
if value is not None:
self.redis.setex(key, self.ttl, json.dumps(value))
return value
# Usage
product_cache = ReadThroughCache(
redis_client,
lambda pid: fetch_product_from_db(pid),
ttl=300
)
product = product_cache.get(f"product:42", 42)
Multi-Level Cache (L1 Memory + L2 Redis)
from functools import lru_cache
@lru_cache(maxsize=1000)
def get_product_l1(product_id: int) -> dict:
# L1: in-memory cache (per-process)
cached = redis_client.get(f"product:{product_id}")
if cached:
return json.loads(cached)
product = fetch_product_from_db(product_id)
if product:
redis_client.setex(f"product:{product_id}", 300, json.dumps(product))
return product
# L1 hit: instant. L1 miss -> L2 (Redis) check -> L2 miss -> DB
Cache with Stale-While-Revalidate
def get_product_swr(product_id: int) -> dict:
cache_key = f"product:{product_id}"
stale_key = f"stale:{cache_key}"
cached = redis_client.get(cache_key)
if cached:
# Check if stale (past TTL but still available)
is_stale = redis_client.exists(stale_key)
if is_stale:
# Return stale data and trigger background refresh
# In production, use a task queue (Celery, RQ) for the refresh
pass
return json.loads(cached)
# Cache miss — fetch from DB
product = fetch_product_from_db(product_id)
if product:
redis_client.setex(cache_key, 300, json.dumps(product))
redis_client.setex(stale_key, 600, "1") # Stale window: 5 extra minutes
return product
Best Practices
-
For a deeper guide, see Complete Guide to Redis Caching Strategies.
-
Cache at the right granularity: Cache complete query results, not individual rows. One cache key per query + parameters.
-
Set TTL even with explicit invalidation: TTL is a safety net. If invalidation fails, stale data self-heals.
-
Use
SETEXinstead ofSET+EXPIRE:SETEXis atomic — the key and TTL are set in one operation. -
Invalidate list caches on writes: When a product is created or deleted, invalidate category-level cache keys, not just the individual product key.
-
Use
MGETfor batch reads: Fetching 100 products one by one is 100 Redis round-trips.MGETdoes it in one. -
Monitor cache hit rate: Below 50% means the cache is misconfigured or the workload isn’t cacheable.
Common Mistakes
- Caching without a TTL: If invalidation fails, stale data persists forever. Always set a TTL.
- Invalidating too broadly: Deleting
product:*when one product changes clears the entire cache. Delete specific keys. - Not handling
Noneresults: If the database returnsNone, caching it prevents repeated DB hits for non-existent keys. Use a sentinel value or short TTL for negative caching. - Cache key collisions: Use descriptive, namespaced keys (
product:42, not just42). Different queries with the same ID will collide. - Forgetting to invalidate after bulk operations:
UPDATE products SET price = price * 1.1changes all products but doesn’t trigger per-key invalidation. Clear the pattern or bump a version.
FAQ
Cache-aside vs read-through — what’s the difference?
In cache-aside, the application code explicitly checks the cache and fetches from the database. In read-through, a cache layer transparently fetches from the database on miss. Cache-aside gives more control; read-through simplifies application code.
How do I cache paginated queries?
Include page number and page size in the cache key: products:category:1:page:3:size:20. When a product in the category changes, invalidate all pages with a pattern: products:category:1:*.
Should I cache JOINs?
Yes, if the JOIN is expensive and the result is consumed frequently. Cache the denormalized result. Invalidate when any of the joined tables change.
What is negative caching?
Caching the fact that a key doesn’t exist (e.g., product:999 returns None). This prevents repeated database queries for non-existent keys. Use a short TTL (30-60 seconds) to avoid caching non-existence for too long.
How do I measure cache effectiveness?
Track cache hits and misses. In Redis, use the INFO stats command to see keyspace_hits and keyspace_misses. Calculate hit rate: hits / (hits + misses). A good hit rate is above 80%.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Cache Function Results with Redis and TTL in Python
Build a Python decorator that caches function return values in Redis with configurable TTL, key generation, and cache invalidation
RecipeCache Database Queries with Django Cache Framework
Use Django's built-in cache framework with per-view caching, template fragment caching, and low-level cache API for database query optimization.
GuideComplete Guide to API Versioning Strategies
Version REST and GraphQL APIs with URI, header, query param, and content negotiation strategies. Covers deprecation, sunset, and migration patterns.
GuideComplete Guide to React Performance Optimization
Optimize React apps for speed. Covers memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features, and Core Web Vitals.
RecipeCache HTTP Responses with httpx and CacheControl in Python
Cache HTTP responses in Python using httpx with CacheControl for HTTP-compliant caching, ETag handling, and conditional requests.
RecipeDistribute Background Tasks with Python Celery and Redis
Set up Celery with Redis broker for distributed task processing including task chaining, groups, chords, retry strategies, scheduled tasks with Celery Beat, and result backends.