Cache Queries with Redis
Learn to cache database queries with Redis to boost application performance. This lesson covers the core concept, step-by-step implementation, hands-on exercises, and troubleshooting tips.
Focus: cache database queries with redis
Imagine your application making the same expensive database query hundreds of times per second — each call hitting the disk, parsing SQL, building rows, and sending data over the network. The result? Sluggish response times, a strained database server, and unhappy users. Caching database queries with Redis offers a simple, lightning-fast escape: store the result of that query once, then serve it from Redis's in-memory key-value store for subsequent requests. This lesson shows you exactly how to implement query caching with Redis, turning repeated reads into near-instant responses.
The problem this lesson solves
Database queries are often the slowest part of an application. A typical SELECT that joins multiple tables can take tens or hundreds of milliseconds — an eternity in the world of HTTP APIs. When the same data is requested by many users (or the same user refreshes), you pay that cost over and over. This leads to:
- Increased database load — the DB must work harder, slowing down writes and other critical queries.
- Higher latency — end users feel the delay, leading to poor UX and lost revenue.
- Scalability bottlenecks — as traffic grows, the database becomes the first component to fail.
By caching the query result in Redis, you reduce the number of round trips to the database. The first request fetches data from the DB and stores it in Redis with a time-to-live (TTL). Subsequent requests read from Redis — often in under a millisecond. Your database breathes a sigh of relief, and your users enjoy snappy responses.
Core concept / mental model
Think of Redis as a super-fast scratchpad sitting right in front of your database. When a query comes in:
- Check the scratchpad — Ask Redis if the result is already stored.
- Cache hit — If found (and not expired), return immediately.
- Cache miss — If not found, run the query on the database, store the result in Redis with a TTL, then return.
Cache-aside pattern
The most common pattern for query caching is cache-aside (also called lazy loading). The application is responsible for both populating and invalidating the cache. This keeps your database as the source of truth — the cache is just a performance accelerator.
Key concepts
- Cache key: A unique string that identifies the query (e.g.,
"user:profile:42"or"posts:recent:10"). - Cache value: The serialized result — often JSON, a pickled Python object, or a simple string.
- TTL (Time-To-Live): How long the cache entry lives before Redis automatically evicts it. Choose a TTL that matches how often your data changes (e.g., 60 seconds for a blog post list, 300 seconds for a user profile).
- Cache invalidation: When data in the database changes, you must delete or update the corresponding cache key to avoid serving stale data.
Pro tip: Never hardcode TTLs. Use configuration or constants, and always set a TTL — otherwise stale data lives forever.
How it works step by step
Let's trace a typical cache-aside workflow for a Python web app that uses Redis and a SQL database (like PostgreSQL).
Step 1: Build a unique cache key
The key must uniquely identify the query and its parameters. A good practice is to include:
- The entity name (e.g., user)
- The action or query type (e.g., profile)
- Any identifiers (e.g., user_id)
Example: "user:profile:42"
Step 2: Check Redis first
Use redis.get(key) to attempt a read. If it returns a non-None value, you have a cache hit. Deserialize it and return.
Step 3: On cache miss, query the database
If the key is missing (or expired), run the database query. This is the slow path — but it happens only once per TTL window.
Step 4: Store the result in Redis
Use redis.setex(key, ttl, value) to store the serialized result with an expiration. The setex command is atomic — set value and TTL in one operation.
Step 5: Return the result
Whether from cache or database, return the data to the caller.
Step 6: Invalidate on updates
When the underlying data changes (e.g., user updates their profile), delete the corresponding cache key using redis.delete(key). On the next read, the cache will miss, and fresh data will be stored.
Hands-on walkthrough
Let's implement a simple cache-aside pattern for a PostgreSQL query. We'll use redis-py and psycopg2. Make sure Redis is running locally (default port 6379).
Setup
First, install the required packages:
pip install redis psycopg2-binary
Example 1: Basic caching of a user profile
import json
import redis
import psycopg2
from psycopg2.extras import RealDictCursor
# Redis connection
redis_client = redis.Redis(host='localhost', port=6379, db=0)
# Database connection (simplified — use connection pooling in production)
def get_db_connection():
return psycopg2.connect(
host='localhost',
dbname='mydb',
user='myuser',
password='mypass'
)
def get_user_profile(user_id: int) -> dict:
cache_key = f"user:profile:{user_id}"
# Step 2: Check Redis
cached = redis_client.get(cache_key)
if cached is not None:
print("Cache HIT")
return json.loads(cached)
# Step 3: Cache miss — query DB
print("Cache MISS — fetching from DB")
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT id, name, email FROM users WHERE id = %s", (user_id,))
row = cur.fetchone()
finally:
conn.close()
# Step 4: Store in Redis with TTL of 300 seconds
redis_client.setex(cache_key, 300, json.dumps(row))
return row
# Usage
print(get_user_profile(42))
print(get_user_profile(42)) # Second call hits cache
Expected output (first run):
Cache MISS — fetching from DB
{'id': 42, 'name': 'Alice', 'email': 'alice@example.com'}
Cache HIT
{'id': 42, 'name': 'Alice', 'email': 'alice@example.com'}
Example 2: Caching a list query with parameters
import json
import hashlib
def get_recent_posts(limit: int = 10) -> list:
# Build a deterministic key from the query parameters
params_str = f"posts:recent:limit={limit}"
cache_key = f"posts:recent:{hashlib.md5(params_str.encode()).hexdigest()}"
cached = redis_client.get(cache_key)
if cached:
print("Cache HIT")
return json.loads(cached)
print("Cache MISS — fetching from DB")
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT %s", (limit,))
rows = cur.fetchall()
finally:
conn.close()
redis_client.setex(cache_key, 60, json.dumps(rows))
return rows
print(get_recent_posts(5))
Example 3: Cache invalidation on update
def update_user_name(user_id: int, new_name: str) -> None:
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute("UPDATE users SET name = %s WHERE id = %s", (new_name, user_id))
conn.commit()
finally:
conn.close()
# Invalidate the cache for this user
cache_key = f"user:profile:{user_id}"
redis_client.delete(cache_key)
print(f"Cache key {cache_key} invalidated")
# Usage
update_user_name(42, "Alice Smith")
print(get_user_profile(42)) # Will miss cache and fetch fresh data
Expected output:
Cache key user:profile:42 invalidated
Cache MISS — fetching from DB
{'id': 42, 'name': 'Alice Smith', 'email': 'alice@example.com'}
Compare options / when to choose what
| Approach | Description | Use case | Pros | Cons |
|---|---|---|---|---|
| Cache-aside (lazy loading) | App checks cache first, writes on miss | General purpose, read-heavy workloads | Simple, flexible, cache only what's needed | Cache misses cause a DB hit; possible stale data if TTL is too long |
| Write-through cache | App writes to cache and DB simultaneously | Data that must be consistent immediately | Always fresh cache; no invalidations needed | Slower writes; cache can become large |
| Cache invalidation by event | Use pub/sub or message queue to clear cache on updates | Distributed systems, high consistency need | Fine-grained control; no TTL guessing | More complex; requires event infrastructure |
| No caching | Query DB every time | Writes > reads, data changes every second | Perfect consistency | Highest DB load, slowest reads |
Pro tip: Start with cache-aside. It's the easiest to get right. Only move to write-through if you have a strong consistency requirement and can tolerate slower writes.
Troubleshooting & edge cases
Common mistakes
- Forgetting to set a TTL — Cache entries live forever, wasting memory and guaranteeing stale data.
- Using the same cache key for different queries — For example, using
"user:42"for both profile and settings data. Always include a namespace. - Not handling serialization properly — Redis stores bytes, so you must
json.dumps()before storing andjson.loads()after retrieving. For complex Python objects, considerpickle, but beware of security risks. - Invalidating on every read — Don't delete a key unless the underlying data actually changed. Doing so defeats the purpose of caching.
- Ignoring Redis connection failures — If Redis is down, your app should fall back to querying the DB directly, not crash.
Edge cases
- Cache stampede: When many concurrent requests all miss the cache and hit the DB simultaneously. Mitigate by using locks or probabilistic early expiration (e.g., Redis
SETNXfor locking, or setting a shorter TTL and refreshing). - Large query results: Storing a 10 MB JSON blob in Redis will blow up memory. Keep cached values small (under 100 KB). Consider pagination or partial data.
- TTL tuning: Too short — high DB load. Too long — stale data. Monitor cache hit rate in production and adjust.
What you learned & what's next
You now understand how to cache database queries with Redis using the cache-aside pattern. You saw how to:
- Build a unique cache key from query parameters.
- Check Redis before hitting the database.
- Store serialized results with a TTL using
setex. - Invalidate the cache when data changes with
delete.
You're ready to apply this pattern to any read-heavy part of your application — user profiles, product listings, configuration data, and more.
Next up: In the next lesson, you'll explore caching for Python functions with functools.cache and how Redis can serve as a distributed function cache — taking this concept global across multiple app instances.
Keep your Redis instance warm and your TTLs short!
Practice recap
Try extending the example: cache the result of a join query (e.g., user with their latest order). Implement a function that fetches a user's last order — use a key like user:last_order:42 with a TTL of 120 seconds. Then write a function to update the order and delete the cache key. Run both functions and verify the cache is invalidated correctly.
Common mistakes
- Forgetting to set a TTL — cache entries live forever, wasting memory and guaranteeing stale data.
- Using the same cache key for different queries — always include a namespace like user:profile:42.
- Not serializing the DB result (e.g., storing a Python dict directly) — Redis stores bytes, so use json.dumps/json.loads.
- Invalidating on every read instead of only on writes — this defeats the purpose of caching.
- Ignoring Redis connection failures — always fall back to querying the database if Redis is unreachable.
Variations
- Write-through caching — update both Redis and the database on every write, ensuring the cache is always fresh at the cost of slower writes.
- Using Redis as a cache for API responses (not just DB queries) — store the entire HTTP response for a URL.
- Leveraging Redis Cluster for distributed caching across multiple nodes when your cache grows beyond a single machine.
Real-world use cases
- E-commerce product catalog — cache product details and inventory counts to handle thousands of reads per second during sales events.
- User session data — cache user profile and preferences (e.g., from a user table) to avoid querying the DB on every page load.
- Leaderboard or recent posts feed — cache the result of a heavy ORDER BY/LIMIT query so multiple users see the same (fresh) data fast.
Key takeaways
- Cache database queries with Redis using the cache-aside pattern to drastically reduce database load and latency.
- Always use a unique, namespaced cache key — never reuse keys across different queries.
- Set a TTL for every cached entry to prevent stale data and memory bloat.
- Invalidate the cache by deleting the key when the underlying data changes — this keeps your cache consistent with the database.
- Handle Redis connection failures gracefully by falling back to the database, ensuring your app remains functional.
- Start with cache-aside; consider write-through only if you need strong consistency and can afford slower writes.
Keep learning
Related tutorials, quizzes, and articles for this topic.
More in this track
Quizzes
Articles
- Why Chasing 'Perfect Code' Is Actually Making Your Team Slower and More Fragile
- The Rise of Python Notebooks as Operational Tools: Why Jupyter Is Moving Beyond Data Science Into DevOps and Monitoring
- The Legacy Database Problem: Why Python Teams Are Migrating to SQLite and DuckDB for Production Workloads
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.