Caching Database Queries
Table of Contents
Why Cache Database Queries?
When your backend grows, database queries often become the slowest part of each request. Every time an API endpoint needs data, it talks to the database, the database reads from disk or memory, runs the query, and sends results back.
If many users request the same data repeatedly, this becomes wasteful. Caching database queries lets you:
- Avoid running the same expensive query over and over.
- Reduce load on the database.
- Return responses much faster.
In this chapter you will see how to cache query results, when it helps, and how to avoid common mistakes.
Key idea: Cache database query results that are:
- Expensive to compute,
- Read frequently,
- Do not change very often.
Types of Data That Benefit from Query Caching
Not all queries are good candidates for caching. Some are better than others.
Good candidates for caching
These queries are usually excellent to cache:
- Public read-heavy data
For example: - List of product categories
- Top 10 trending posts
- Latest news articles for the homepage
- Exchange rates updated once per day
- Expensive aggregations
For example: SELECT COUNT(*) FROM orders WHERE created_at >= ...- Monthly sales summaries
- Dashboard statistics
- Reference data and configuration
For example: - Country list, currency list
- Feature flags
- App settings stored in the database
- Slow joins and complex queries
For example: - A query joining 5 tables for a complex report
- A search query that needs several conditions and sorting
Poor candidates for caching
Some queries should usually not be cached, or only very briefly:
- Highly user specific and constantly changing data
For example: - "Show my notifications" when many new notifications arrive every second
- A live trading balance that updates after every transaction
- Data that must always be fresh and accurate
For example: - Remaining stock level in a high traffic flash sale
- Real time payment status
In these cases, caching can introduce bugs if the cache is slightly out of date.
Rule of thumb
If running a query often is expensive and the answer does not change every millisecond, you can usually cache it for at least a short time.
Basic Pattern: Read-Through Cache for Queries
The classic pattern for caching a query result is called read through.
Conceptually, you do this:
- Look in the cache for the query result.
- If present, return it.
- If not present, run the query against the database.
- Store the result in the cache.
- Return the result.
In pseudocode:
def get_something(params):
key = make_cache_key(params)
cached = cache.get(key)
if cached is not None:
return cached
# Cache miss
data = run_query(params)
cache.set(key, data, ttl=60) # store for 60 seconds
return dataYou will see real examples shortly. First, you need to understand how to build a good cache key.
Designing Cache Keys for Queries
A cache key identifies a cached value. For database query caching, the key usually represents:
- What you are querying, and
- Which parameters affect the result.
Rule: If two different requests could return different query results, they must use different cache keys.
Components of a query cache key
Common parts of a key:
- Resource name (what this data represents)
- Query parameters that affect the result
- User identity or role if the data is user specific
- Sometimes, a version number
Example structure:
"{prefix}:{resource}:{params_hash}"or
"{prefix}:{resource}:{param1}:{param2}:{...}"Simple key example
Imagine an endpoint:
GET /products?category=books&page=2&page_size=20&sort=price_asc
A possible key:
"products:list:category=books:page=2:page_size=20:sort=price_asc"In Python:
def make_products_list_key(category, page, page_size, sort):
return f"products:list:category={category}:page={page}:size={page_size}:sort={sort}"Keys with many parameters
If your query has many parameters, long keys can become messy. In these cases, you can:
- Serialize parameters as JSON.
- Hash the JSON to create a compact string.
Example:
import json
import hashlib
def make_cache_key(prefix: str, **params) -> str:
# Sort keys to keep order stable
params_json = json.dumps(params, sort_keys=True)
params_hash = hashlib.sha256(params_json.encode()).hexdigest()
return f"{prefix}:{params_hash}"Usage:
key = make_cache_key(
"products:list",
category="books",
page=2,
page_size=20,
sort="price_asc",
)The actual key looks like:
"products:list:8a2a9c1d8b7f..."Easy to store, stable, and unique for each parameter combination.
Where to Cache: Per Query or Per Entity?
You can cache at different levels.
1. Cache whole query results
Cache the entire result set for a query, usually as a JSON blob.
Example:
def get_products_list(category, page, page_size, sort):
key = make_products_list_key(category, page, page_size, sort)
cached = redis.get(key)
if cached:
return json.loads(cached)
# Run database query
products = db.fetch_products(category, page, page_size, sort)
redis.setex(key, 60, json.dumps(products)) # 60-second cache
return productsPros:
- Very simple to implement.
- Good performance for read heavy lists.
Cons:
- Every small change that affects the list can make the cache stale.
- Harder to reuse across endpoints.
2. Cache individual entities by ID
Sometimes you cache each row separately, for example by user:{id} or product:{id}.
Example:
def get_product_by_id(product_id: int):
key = f"product:{product_id}"
cached = redis.get(key)
if cached:
return json.loads(cached)
product = db.fetch_product_by_id(product_id)
if product is None:
return None
redis.setex(key, 300, json.dumps(product))
return productPros:
- Easy to invalidate when a single record changes.
- Reusable across many endpoints.
Cons:
- For list endpoints, you may need another caching layer or to compose many cached entities together.
3. Combine both levels
Common real world approach:
- Cache entity by ID, for example
product:{id}. - Cache queries that return only IDs for a particular list.
- When serving a list:
- Get list of IDs from cache.
- For each ID, get the product from the entity cache.
- Hit the database as a fallback only when needed.
This gives more flexibility in invalidation and reuse.
Serialization: Storing Query Results in the Cache
Most caches, like Redis, store strings or binary blobs. Database query results in Python are often:
- Dictionaries
- Lists of dictionaries
- ORM model objects
You must serialize these objects.
Common formats
| Format | Pros | Cons |
|---|---|---|
| JSON | Human readable, portable, language independent | Only basic types, slow for very large objects |
| Pickle | Simple for Python objects | Python only, security risk if misused |
| MessagePack | Compact, faster than JSON | Needs extra library |
For APIs, JSON is usually the safest choice.
Example: JSON serialization in Python
import json
def cache_set_json(key: str, value, ttl: int | None = None):
data = json.dumps(value)
if ttl:
redis.setex(key, ttl, data)
else:
redis.set(key, data)
def cache_get_json(key: str):
data = redis.get(key)
if data is None:
return None
return json.loads(data)Use these helpers in your query caching code:
def get_top_posts(limit: int = 10):
key = f"posts:top:{limit}"
cached = cache_get_json(key)
if cached is not None:
return cached
posts = db.fetch_top_posts(limit=limit)
cache_set_json(key, posts, ttl=120)
return postsChoosing a Time To Live (TTL) for Query Caches
The TTL decides how long a cached value stays valid. When TTL expires, the cache entry disappears and you have to fetch fresh data from the database.
There is a trade off:
- Short TTL
- Pros: Fresher data. Lower risk of stale results.
- Cons: Less effective caching. More DB load.
- Long TTL
- Pros: Better performance, fewer DB queries.
- Cons: Data can become stale.
Practical TTL guidelines
Different queries may use different TTL values. Here are some rough ideas:
| Data type | Example | Suggested TTL |
|---|---|---|
| Rarely changing reference data | Country list | 1 hour to 24 hours |
| Homepage / landing page lists | Latest articles | 30 to 300 seconds |
| Trending / top lists | Top posts, top products | 30 to 300 seconds |
| Dashboard statistics | Daily summary | 60 to 600 seconds |
| Per user light personalization | "Recently viewed" | 30 to 120 seconds |
This is not strict. You adjust based on your system.
Use different TTLs in the same app
Your application can easily apply different TTLs:
TTL = {
"reference": 3600,
"homepage": 120,
"top_list": 180,
"user_data": 60,
}Then:
cache_set_json(key, value, ttl=TTL["top_list"])Cache Invalidation for Database Queries
The tricky part of caching is not storing or reading values. It is invalidation. Invalidation means making sure the cache does not serve outdated results when the underlying database changes.
Rule: Any time data changes in the database in a way that affects a cached query, the cache must be invalidated or refreshed, or the TTL must be short enough that staleness is acceptable.
There are two main strategies:
- Passive invalidation with TTLs.
- Active invalidation when data changes.
You can also combine both.
1. Passive invalidation (rely on TTLs)
In this approach:
- You use small enough TTL values.
- You do not explicitly delete keys when data changes.
- You accept that data can be slightly outdated.
Example:
def get_latest_articles():
key = "articles:latest"
cached = cache_get_json(key)
if cached is not None:
return cached
articles = db.fetch_latest_articles(limit=10)
cache_set_json(key, articles, ttl=60)
return articlesIf a new article is added, the cache might still serve the old list for up to 60 seconds. For many use cases, this is acceptable.
Pros:
- Simple to implement.
- No special logic on write operations.
Cons:
- Data can be temporarily stale.
- Hard to guarantee consistency.
2. Active invalidation on writes
In this approach, whenever something changes in the database, you actively remove or update cache entries that depend on that data.
For example:
- When a product is updated:
- Delete
product:{id}. - Possibly delete lists that contain that product, for example
products:list:*.
Basic pattern:
def update_product(product_id: int, updates: dict):
product = db.update_product(product_id, updates)
# Invalidate caches
redis.delete(f"product:{product_id}")
redis.delete("products:top:10")
redis.delete("products:latest:10")
# etc.
return productPros:
- More consistent results.
- You can use longer TTLs.
Cons:
- You must track all related cache keys.
- Easy to forget some keys if the system grows.
Using key prefixes for easier invalidation
Instead of listing every key, you can use key prefixes and delete all keys with a given prefix. For Redis you often use SCAN plus DEL.
Example structure:
product:by_id:{id}product:list:category={category}:...
To invalidate all product lists:
def invalidate_product_lists():
pattern = "product:list:*"
cursor = 0
while True:
cursor, keys = redis.scan(cursor=cursor, match=pattern, count=100)
if keys:
redis.delete(*keys)
if cursor == 0:
breakHowever, scanning in production must be used carefully and not too often. It can be slow if you have many keys.
Combining Entity Cache and Query Cache
Suppose you have:
- Product cached by ID.
- Multiple list queries that return product lists.
One robust pattern:
- Cache each product by ID:
- Key:
product:{id}. - Cache each list as a list of IDs only:
- Key:
product:list:category=books:page=1:.... - When serving a list:
- Get list of product IDs from list cache.
- For each ID, fetch product from
product:{id}, then combine into a list.
Example
def get_product_by_id(product_id: int):
key = f"product:{product_id}"
cached = cache_get_json(key)
if cached is not None:
return cached
product = db.fetch_product_by_id(product_id)
if not product:
return None
cache_set_json(key, product, ttl=300)
return product
def get_products_list(category: str, page: int, page_size: int):
list_key = make_products_list_key(category, page, page_size)
cached_ids = cache_get_json(list_key)
if cached_ids is not None:
products = []
for pid in cached_ids:
product = get_product_by_id(pid)
if product:
products.append(product)
return products
# Cache miss for list of IDs
products = db.fetch_products(category, page, page_size)
ids = [p["id"] for p in products]
cache_set_json(list_key, ids, ttl=60)
# Also warm up entity cache
for p in products:
cache_set_json(f"product:{p['id']}", p, ttl=300)
return productsBenefits:
- If a single product changes:
- You can delete
product:{id}. - The next request will fetch a fresh product from DB but still reuse the list of IDs.
- You can build many different list queries (by category, by tag, by price, etc) that share the same entity cache.
Example: Caching a Frequently Used Report
Imagine an endpoint for a dashboard:
GET /reports/orders-summary?month=2024-01
The database query calculates:
- Total number of orders
- Total revenue
- Average order value
The query touches large tables and is slow, about 1 second.
Step 1: Define a cache key
def make_orders_summary_key(month: str) -> str:
return f"reports:orders_summary:month={month}"Step 2: Implement caching
def get_orders_summary(month: str):
key = make_orders_summary_key(month)
cached = cache_get_json(key)
if cached is not None:
return cached
# Slow query
summary = db.fetch_orders_summary(month=month)
# Cache for 10 minutes
cache_set_json(key, summary, ttl=600)
return summaryThis is usually enough because summaries do not change often once the month is complete.
Step 3: Handle updates
If your system allows modifying orders of past months, you have two options:
- Short TTL
Keep TTL small, for example 60 seconds, and accept minor delay. - Invalidate when updating orders
When an order changes month or amount:
def update_order(order_id: int, updates: dict):
old_order = db.fetch_order(order_id)
order = db.update_order(order_id, updates)
# Invalidate affected months
if "month" in updates:
old_month = old_order["month"]
new_month = order["month"]
redis.delete(make_orders_summary_key(old_month))
redis.delete(make_orders_summary_key(new_month))
else:
month = order["month"]
redis.delete(make_orders_summary_key(month))
return orderNow your monthly summary cache always becomes correct after any update.
Avoiding Cache Stampede
A cache stampede happens when:
- A popular key expires.
- Many requests arrive at the same time.
- All of them miss the cache and hit the database.
- The database gets a sudden spike of load.
Example scenario
- Key
homepage:latest_postsexpires at 12:00:00. - At 12:00:01, 1,000 users hit the homepage.
- All 1,000 requests try to recalculate
latest_postsfrom the database.
This defeats the purpose of caching.
Simple strategies to reduce stampedes
You can use several techniques without complex tools.
1. Staggered or random TTL
Instead of a fixed TTL like 60 seconds, use a small random variation, for example 50 to 70 seconds. This way, not all keys expire at the exact same moment.
import random
def set_with_jitter(key, value, base_ttl: int):
jitter = random.randint(-10, 10) # from -10 to +10 seconds
ttl = max(10, base_ttl + jitter)
cache_set_json(key, value, ttl=ttl)Use it:
set_with_jitter(key, value, base_ttl=60)2. Soft TTL and background refresh
You can combine:
- A longer TTL for serving slightly stale data.
- A shorter "soft" TTL to decide when to refresh in the background.
Very simple version:
- If the cached item is close to expiring, return it anyway and also trigger a background task to recalculate it.
Full implementation is more advanced, but the idea is:
- Store
valueandexpires_atin the cache. - If current time is before
expires_at, serve from cache. - If close to
expires_at, serve from cache but also start a background job to refresh.
This reduces many simultaneous database hits.
Caching with ORM Queries
When you use an ORM like SQLAlchemy, you usually work with model objects, not raw SQL. You can still apply exactly the same caching patterns, but you must serialize ORM objects to JSON or dictionaries first.
Converting ORM objects
Suppose you have a Product model with a .to_dict() method:
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True)
name = Column(String)
price = Column(Numeric)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"price": float(self.price),
}Then your caching function becomes:
def get_product_by_id(session, product_id: int):
key = f"product:{product_id}"
cached = cache_get_json(key)
if cached is not None:
return cached
product = session.query(Product).get(product_id)
if not product:
return None
data = product.to_dict()
cache_set_json(key, data, ttl=300)
return dataFor lists:
def get_products_list(session, category: str, page: int, page_size: int):
key = make_products_list_key(category, page, page_size)
cached = cache_get_json(key)
if cached is not None:
return cached
query = (
session.query(Product)
.filter(Product.category == category)
.order_by(Product.id)
.offset((page - 1) * page_size)
.limit(page_size)
)
products = [p.to_dict() for p in query.all()]
cache_set_json(key, products, ttl=60)
return productsThe database and ORM work exactly as before. The cache sits in front and stores serialized results.
Measuring Benefits of Query Caching
You should not guess whether query caching helps. You can measure it.
Useful metrics
Track:
- Number of cache hits and misses per endpoint.
- Latency of responses with and without cache.
- Average and peak load on your database.
For example, you can add simple logging:
def get_with_cache(key, loader, ttl: int):
value = cache_get_json(key)
if value is not None:
log.info("cache_hit", key=key)
return value
log.info("cache_miss", key=key)
value = loader()
cache_set_json(key, value, ttl=ttl)
return valueThen use:
def get_latest_articles():
key = "articles:latest"
return get_with_cache(key, loader=db.fetch_latest_articles, ttl=60)Over time, you can see how often your cached queries avoid hitting the database.
Common Pitfalls and How to Avoid Them
1. Forgetting to include parameters in the key
If you accidentally ignore a parameter when constructing cache keys, different queries may share the same cached result.
Example mistake:
# Bug: missing "sort" in the key
def make_key(category, page, page_size, sort):
return f"products:list:category={category}:page={page}:size={page_size}"
Then requests with sort=price_asc and sort=price_desc share the same cache entry and return incorrect data.
Always ensure every parameter that can change the result is reflected in the key.
2. Caching non deterministic queries
Do not cache queries that are random by definition, for example:
SELECT * FROM posts ORDER BY RANDOM() LIMIT 5;If you cache this for 1 hour, "random" will look very fixed. If you want fresh randomness, you may choose not to cache or to cache for a very short time.
3. Caching very large result sets
If a query returns huge lists (for example tens of thousands of rows) and you try to cache the entire list as JSON, you:
- Use a lot of memory.
- Use more network bandwidth between app and cache.
- Possibly slow down serialization.
In such cases:
- Paginate your API.
- Cache page by page.
- Or cache only IDs, not full objects.
4. Not handling "not found" results
If an item is not found in the database, you might want to cache that negative result for a short time, particularly if that lookup is expensive.
Example, caching None:
def get_user_by_email(email: str):
key = f"user:by_email:{email}"
cached = cache_get_json(key)
if cached is not None:
# We store {"found": False} or similar
if not cached["found"]:
return None
return cached["user"]
user = db.fetch_user_by_email(email)
if not user:
cache_set_json(key, {"found": False}, ttl=60)
return None
cache_set_json(key, {"found": True, "user": user}, ttl=300)
return userThis avoids repeated expensive lookups for missing users.
Summary
Caching database queries can dramatically improve your backend performance and relieve pressure on your database. The essential ideas are:
- Use read through caching: try cache first, then database, then store.
- Design cache keys that uniquely represent the query and its parameters.
- Choose TTL values that balance freshness and performance.
- Plan invalidation carefully, with TTLs, explicit deletes, or both.
- Consider caching:
- Individual entities by ID.
- Lists of entity IDs.
- Aggregated reports and statistics.
- Be aware of cache stampede and basic mitigation techniques.
- Serialize query results to JSON or another suitable format before storing in cache.
With these patterns you can confidently apply caching to database queries in your own backend projects and understand the trade offs you are making.
Views: 9
KAHIBARO