Caching Flask-SQLAlchemy Queries with Redis
Database queries are often the bottleneck in web applications. When using Flask-SQLAlchemy, complex joins or large dataset retrievals can introduce latency that affects user experience. By implementing a Redis caching layer, we can store frequently accessed query results, reducing database load and speeding up response times. In this guide, we’ll walk through caching a heavy query—specifically fetching users by region—and demonstrate how to manage cache keys, set time-to-live (TTL) values, and handle invalidation strategies to keep data consistent.
Why Cache Database Queries with Redis
Flask-SQLAlchemy ORM queries can become a bottleneck in production environments. Common issues include N+1 query problems and complex joins, which often result in latency ranging from 200-500ms per request.
Redis addresses this by storing query results in memory. Because Redis operations are typically sub-millisecond, retrieving cached data is significantly faster than re-executing a database query. Additionally, Redis supports Time-To-Live (TTL) eviction to automatically manage memory, and Lua scripts can handle atomic invalidation to keep data consistent. This approach can yield substantial improvements in response time—for example, 5x faster in workloads with high cache hit rates.
| Factor | No Cache | Redis Cache |
|---|---|---|
| Cold Query | 250ms DB | 250ms |
| Cache Hit | 250ms DB | 1ms |
| Hit Rate | 0% | 80% |
| Endpoint | 280ms | 55ms |
| QPS | 3.5 | 18 |
Choosing a Cache Backend: Why Redis?\n\nRedis stands out for its combination of speed, data structures beyond simple key-value (lists, sets, hashes), built-in TTL expiration, optional persistence, and support for Lua scripts enabling atomic multi-key operations like cache invalidation.\n\nWe can compare it to common alternatives:\n\n| Backend | Pros | Cons |\n|---------|------|------|\n| Redis | Sub-ms latency, rich types, persistence, clustering, Lua scripting | Higher memory/CPU, operational complexity |\n| Memcached | Extremely lightweight, battle-tested | No persistence, basic key-value only, no lists/sets |\n| In-process (e.g., functools.lru_cache) | Zero network overhead, simple | Per-process only (no sharing in multi-worker), no TTL/distribution |\n\nFor Flask apps in production—especially with Gunicorn/uWSGI multiple workers—Redis allows cache sharing across processes. Though it introduces a service to manage, the performance gains often justify it for read-heavy endpoints.\n\nConsider not caching when data changes frequently (staleness risk), queries are cheap/simple, or for highly sensitive data where duplication raises concerns.\n\n## Project Setup
Docker Redis (dev):
docker run -d -p 6379:6379 --name flask-redis redis:7-alpine
redis-cli ping # PONG
pip install flask flask-sqlalchemy redis "Werkzeug>=2.3" gunicorn faker # faker for data
Baseline Flask App Without Caching
models.py:
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import String, Integer
db = SQLAlchemy()
class User(db.Model):
id = db.Column(Integer, primary_key=True)
username = db.Column(String(80), unique=True)
region = db.Column(String(10))
email = db.Column(String(120))
app.py (heavy query):
from flask import Flask, jsonify
from models import db, User
import time
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
@app.route('/users/us')
def users_us():
start = time.time()
# Simulate a heavy query with a large dataset
users = User.query.filter_by(region='US').limit(1000).all() # 250ms simulated latency
result = [{'id': u.id, 'username': u.username} for u in users]
print(f"Query: {time.time() - start:.0f}ms")
return jsonify(result)
if __name__ == '__main__':
with app.app_context():
db.create_all()
# Seed 10k users
for i in range(10000):
u = User(username=f'user{i}', region='US' if i%3 else 'EU', email=f'u{i}@ex.com')
db.session.add(u)
db.session.commit()
app.run()
Benchmark (wrk): wrk -t4 -c100 -d30s http://localhost:5000/users/us → 3.5 req/s, 280ms p95.\n\nNote: Run on localhost with SQLite in-memory DB, 10k seeded users. Real PostgreSQL/MySQL on network may show higher baseline latency.
Redis Caching Decorator
cache.py: We’ll implement a decorator that handles the caching logic. This decorator generates a unique key for each function call, checks Redis for existing data, and stores the result if it’s a cache miss.
import redis
import hashlib
import pickle
from functools import wraps
from typing import Callable, Any
# Connect to Redis (decode_responses=False is required for pickle)
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=False)
def cache_query(ttl: int = 300) -> Callable:
def decorator(f: Callable) -> Callable:
@wraps(f)
def wrapper(*args, **kwargs) -> Any:
# Create a deterministic key based on function name and arguments
# We use pickle to serialize the arguments for hashing
key = hashlib.sha256(pickle.dumps((f.__name__, args, kwargs))).hexdigest()
# Check if the result is already cached
cached = r.get(key)
if cached:
# Deserialize and return the cached result
return pickle.loads(cached)
# Execute the function and cache the result
result = f(*args, **kwargs)
r.setex(key, ttl, pickle.dumps(result))
return result
return wrapper
return decorator
app.py (cached):
from cache import cache_query
@app.route('/users/us')
@cache_query(ttl=300) # Cache for 5 minutes
def users_us():
start = time.time()
users = User.query.filter_by(region='US').limit(1000).all()
result = [{'id': u.id, 'username': u.username} for u in users]
print(f"Query: {time.time() - start:.0f}ms (cache miss)")
return jsonify(result)
Hits: A cache hit retrieves data in ~1ms. Invalidation: We must invalidate the cache when data changes. In development, r.flushdb() clears everything. In production, use targeted invalidation (e.g., Lua scripts or specific key deletion) to avoid wiping the entire cache.
Invalidation: Post-Write Flush (Prod)
Development: For development, flush the entire cache after a write operation:
@app.route('/create')
def create_user():
# ... create user logic ...
db.session.commit()
r.flushdb() # Clears entire Redis database
return "User created"
Prod: In production, flushing the entire database is too aggressive. Instead, use targeted invalidation. You can tag keys (e.g., user:region:US) or use Lua scripts to delete matching patterns:
# Example Lua script for pattern deletion
SCRIPT = """
local keys = redis.call('keys', ARGV[1])
for i=1,#keys do
redis.call('del', keys[i])
end
"""
r.eval(SCRIPT, 0, 'user:*')\n\nFor more robust invalidation, consider these strategies:\n\n- **Key tagging**: Prefix keys like `user:region:US:v1`, delete by pattern.\n- **Versioning**: On writes, increment a version counter; include in cache key (e.g., `query:users_us:v42`). Cache misses until version updates.\n- **Pub/Sub**: Use Redis pub/sub to notify workers of invalidations.\n\nVersioning avoids pattern scans (O(N)), works with sharding.
Benchmark: 18 req/s, 55ms p95 (80% hit rate).
Benchmarks Table (Gunicorn 4 Workers)
| Scenario | p95 Latency | QPS | DB Load |
|---|---|---|---|
| No Cache | 280ms | 3.5 | 100% |
| Cache Miss | 280ms | 3.5 | 100% |
| Cache Hit | 55ms | 18 | 20% |
| 80% Hit Rate | 65ms | 15 | 20% |
gunicorn -w4 -b 0.0.0.0:5000 app:app\n\nNote: Assumes 80% cache hit rate from repeated requests. Actual hit rates depend on query patterns, data volatility, traffic distribution. Test with your workload.
Pitfalls & Fixes
| Issue | Cause | Fix |
|---|---|---|
| Pickle fails | Non-serializable objects | Use json.dumps with to_dict() methods or ensure objects are serializable |
| Key collision | Poor hashing | Use SHA256 + namespace flask:{hash} for uniqueness |
| Race condition | Multi-write concurrency | Use Redis WATCH/MULTI/EXEC for atomic operations |
| Memory leak | No TTL expiration | Always use setex(ttl) to auto-expire keys |
| Docker Redis | Network connectivity | Use redis://redis:6379 in Docker Compose networking |
| SQLAlchemy scope | Session lifecycle issues | Ensure @cache_query runs within an application context |
Advanced: Query Key from SQLAlchemy Statement
For dynamic queries where parameters change frequently, hashing function arguments might not be sufficient. Instead, you can generate a key based on the compiled SQL statement.
This approach is useful when you have complex queries with variable filters. By compiling the statement with literal_binds=True, we get a SQL string with the values embedded, which can then be hashed.
def query_key(stmt):
# Compile the SQLAlchemy statement to a SQL string with literal binds
return hashlib.sha256(str(stmt.compile(compile_kwargs={"literal_binds": True})).encode()).hexdigest()
# Usage in your route:
# key = f"query:{query_key(User.query.filter_by(region=region))}"
Checklist
- Docker Redis +
pip install redis -
@cache_queryon read endpoints - Invalidate on writes (
r.deleteor tags) - Benchmark:
wrkbefore/after - Prod: Redis Cluster/Sentinel
Related
Redis caching can substantially improve Flask-SQLAlchemy query performance. Experiment with the code above in your application.
Sponsored by Durable Programming
Need help maintaining or upgrading your Python application? Durable Programming specializes in keeping Python apps secure, performant, and up-to-date.
Hire Durable Programming