← Back to Redis Caching for Spatial Queries
Bounding box queries carry high-cardinality parameters: a viewport shift of 0.0001 degrees or a minor zoom change generates a completely new cache key, making conventional per-key invalidation unmanageable at scale. This page shows how to group those keys under spatial tag Sets in Redis so you can invalidate an entire geographic grid cell in a single atomic pipeline when PostGIS geometry changes.
Context & When to Use
Standard cache strategies break down for spatial bounding box endpoints because the parameter space is effectively infinite. A delivery fleet map can produce thousands of distinct bbox strings in a single hour of normal use. If a driver’s geometry is updated in PostGIS, you need to expire every cached response that could contain that driver — but you cannot enumerate those keys ahead of time.
The tag-based approach solves this by introducing a level of indirection. Every bbox cache key is registered into a Redis Set that represents the coarse grid cell it falls within. When geometry in a cell changes, you fetch the Set membership and purge the whole batch. The cost is two extra Redis commands on write (SADD) and one pipeline on invalidation (SMEMBERS + UNLINK + DEL), both of which are negligible compared to the PostGIS query they replace.
Use this pattern when: your bbox parameters are user-driven and unpredictable (map panning, viewport resizing); your underlying spatial data changes frequently enough that stale responses are a correctness concern; and you need sub-50 ms read latency under high concurrency. If your spatial data is nearly static, a simpler approach — a global TTL plus periodic cache warming — is sufficient. For the foundational key structure and connection setup, read the Redis Caching for Spatial Queries guide first.
This technique also complements Query Plan Analysis & Index Tuning work: once you know which ST_Intersects or ST_Within calls dominate your EXPLAIN ANALYZE output, tag-driven caching is the next layer to add so those expensive plans run as rarely as possible.
Tag Architecture Diagram
The diagram below shows the data flow from an incoming bbox request through the Redis tag layer to PostGIS, and the separate invalidation path triggered by a geometry mutation.
Runnable Implementation
The implementation below uses redis-py 5.0+ async client and Python 3.10+. It covers coordinate normalization, deterministic key generation, atomic cache writes with tag registration, and the invalidation routine. Wire invalidate_layer_bbox into any FastAPI mutation route that persists geometry to PostGIS.
For the PostGIS query inside fetch_features_from_db, use ST_Intersects or ST_Within with a properly maintained GIST index — see Bounding Box & Spatial Index Queries for the full index setup. If you are returning large feature sets, consider the GeoJSON vs GeoParquet Serialization decision matrix before choosing a serialization format for the cached payload.
import math
import json
from typing import Optional
from fastapi import FastAPI, Query, Depends
import redis.asyncio as aioredis
app = FastAPI()
# ------------------------------------------------------------------
# Connection — reuse a single pool across all requests
# ------------------------------------------------------------------
redis_pool = aioredis.ConnectionPool(
host="localhost", port=6379, db=0,
decode_responses=True, max_connections=50
)
async def get_redis() -> aioredis.Redis:
return aioredis.Redis(connection_pool=redis_pool)
# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
CACHE_TTL = 3600 # seconds — fallback TTL if invalidation is missed
TAG_TTL = 5400 # tag Set TTL — slightly longer than CACHE_TTL
PRECISION = 4 # ~11 m accuracy at mid-latitudes
GRID_SIZE = 1.0 # 1°×1° grid cell for tag derivation
# ------------------------------------------------------------------
# Key helpers
# ------------------------------------------------------------------
def normalize_bbox(minx: float, miny: float,
maxx: float, maxy: float) -> tuple[float, float, float, float]:
"""Round all bbox edges to PRECISION decimal places."""
return (
round(minx, PRECISION), round(miny, PRECISION),
round(maxx, PRECISION), round(maxy, PRECISION),
)
def cache_key(layer: str, bbox: tuple) -> str:
"""Deterministic cache key from layer + normalized bbox."""
return f"cache:bbox:{layer}:{bbox[0]}:{bbox[1]}:{bbox[2]}:{bbox[3]}"
def tag_key(layer: str, bbox: tuple) -> str:
"""Redis Set key representing the grid cell containing this bbox center."""
cx = (bbox[0] + bbox[2]) / 2
cy = (bbox[1] + bbox[3]) / 2
gx = math.floor(cx / GRID_SIZE) * GRID_SIZE
gy = math.floor(cy / GRID_SIZE) * GRID_SIZE
return f"tag:bbox:{layer}:{gx}:{gy}"
# ------------------------------------------------------------------
# Cache-aside read with tag registration
# ------------------------------------------------------------------
async def fetch_features_from_db(
layer: str, bbox: tuple, redis: aioredis.Redis
) -> dict:
"""
Replace this stub with your asyncpg + PostGIS call.
Example SQL:
SELECT ST_AsGeoJSON(geom) FROM features
WHERE layer = $1
AND ST_Intersects(geom, ST_MakeEnvelope($2,$3,$4,$5, 4326))
"""
return {"type": "FeatureCollection", "features": []}
@app.get("/api/features")
async def get_features(
layer: str = Query(...),
minx: float = Query(...), miny: float = Query(...),
maxx: float = Query(...), maxy: float = Query(...),
redis: aioredis.Redis = Depends(get_redis),
):
bbox = normalize_bbox(minx, miny, maxx, maxy)
ckey = cache_key(layer, bbox)
# 1. Cache lookup
cached = await redis.get(ckey)
if cached:
return {"source": "cache", "data": json.loads(cached)}
# 2. Cache miss — query PostGIS
data = await fetch_features_from_db(layer, bbox, redis)
payload = json.dumps(data)
# 3. Write cache entry + register in tag Set (one pipeline, non-transactional)
tkey = tag_key(layer, bbox)
async with redis.pipeline(transaction=False) as pipe:
pipe.setex(ckey, CACHE_TTL, payload) # cache the response
pipe.sadd(tkey, ckey) # register key in grid-cell tag
pipe.expire(tkey, TAG_TTL) # safety TTL on the tag Set itself
await pipe.execute()
return {"source": "db", "data": data}
# ------------------------------------------------------------------
# Invalidation — call this inside mutation routes
# ------------------------------------------------------------------
async def invalidate_layer_bbox(
layer: str,
affected_bbox: tuple,
redis: aioredis.Redis,
) -> int:
"""
Purge all cache keys whose tag Set covers affected_bbox.
Returns the number of keys removed.
For updates that span multiple grid cells, compute all affected
tag keys and call this function once per cell (or fan out with
asyncio.gather for large updates).
"""
tkey = tag_key(layer, affected_bbox)
keys = await redis.smembers(tkey)
if not keys:
return 0
# UNLINK is non-blocking (background thread); DEL would block the event loop
async with redis.pipeline(transaction=False) as pipe:
pipe.unlink(*keys) # async key deletion — does not block Redis
pipe.delete(tkey) # remove the tag Set itself
await pipe.execute()
return len(keys)
@app.post("/api/features/{feature_id}")
async def update_feature(
feature_id: int,
layer: str = Query(...),
minx: float = Query(...), miny: float = Query(...),
maxx: float = Query(...), maxy: float = Query(...),
redis: aioredis.Redis = Depends(get_redis),
):
bbox = normalize_bbox(minx, miny, maxx, maxy)
# ... persist geometry to PostGIS here ...
removed = await invalidate_layer_bbox(layer, bbox, redis)
return {"invalidated_keys": removed}Key Parameters & Options
| Parameter | Default | Effect |
|---|---|---|
PRECISION | 4 | Decimal places for coordinate rounding. 4 ≈ 11 m; 5 ≈ 1 m. Lower values increase hit rate but risk boundary mismatches. |
GRID_SIZE | 1.0 (degrees) | Width/height of each tag cell. Smaller grids reduce over-invalidation but create more tag Sets. Use 0.1 for city-scale datasets, 1.0 for regional, 5.0 for continental. |
CACHE_TTL | 3600 s | Fallback TTL applied to every cache entry. Caps stale exposure if a mutation event is missed. |
TAG_TTL | 5400 s | TTL applied to the tag Set itself. Should exceed CACHE_TTL so entries always expire before their tag Set disappears. |
transaction=False | — | Pipelines without MULTI/EXEC. Correct here because we do not need rollback semantics; removing it avoids the round-trip cost of MULTI. |
UNLINK vs DEL | UNLINK preferred | UNLINK defers memory reclamation to a background thread. Use DEL only if you need guaranteed synchronous deletion (rarely needed in production). |
Gotchas & Failure Modes
Orphaned tag Sets after missed mutations. If a geometry update bypasses
invalidate_layer_bbox(e.g. a direct SQLUPDATEoutside the API), the tag Set persists and stale cache keys remain live untilCACHE_TTLexpires. Always applyTAG_TTLon the tag Set andCACHE_TTLon every cache entry as independent safety nets. Never rely on invalidation alone.Grid boundary splits. A bbox that straddles a grid cell boundary registers only in the cell containing its center. A geometry update in the adjacent cell will not purge it. For datasets with frequent edits near grid lines, reduce
GRID_SIZEor use a multi-cell registration strategy: compute all grid cells that intersect the bbox andSADDthe cache key into every relevant tag Set.Tag Set memory growth under high write volume. Each
SADDcall adds one string entry to the Set. Under sustained load, a popular grid cell can accumulate tens of thousands of entries. Monitor withMEMORY USAGE tag:bbox:*and trackSCARDon hot tag Sets. If a Set exceeds ~10k members, the invalidation pipeline stalls noticeably — consider sharding by zoom level or adding a secondary expiry sweep.Race between cache write and invalidation. In a concurrent environment, a mutation event can arrive between the PostGIS query and the
pipeline.execute()call that writes the cache entry. The new cache entry contains stale data but lacks its tag registration, so it will never be invalidated by tag. Mitigate by adding a shortCACHE_TTL(60–300 s) for data that mutates frequently, so any stale window is bounded.pipeline(transaction=False)does not guarantee atomicity. Commands in a non-transactional pipeline are sent in bulk but can be interrupted if the connection drops mid-flight. IfSETEXsucceeds butSADDdoes not, the cache key is live but untagged. Detect this via a periodic reconciliation job that scanscache:bbox:*keys and checks each one against its expected tag Set.
Verification Snippet
After deploying, confirm the tag mechanism works end-to-end:
# 1. Make a cacheable request
curl -s "http://localhost:8000/api/features?layer=roads&minx=-0.1278&miny=51.5074&maxx=-0.0978&maxy=51.5274"
# Expect: {"source": "db", ...}
# 2. Confirm cache entry exists
redis-cli GET "cache:bbox:roads:-0.1278:51.5074:-0.0978:51.5274"
# 3. Confirm tag Set membership
redis-cli SMEMBERS "tag:bbox:roads:-1.0:51.0"
# Expect: 1) "cache:bbox:roads:-0.1278:51.5074:-0.0978:51.5274"
# 4. Trigger invalidation via mutation route
curl -s -X POST "http://localhost:8000/api/features/42?layer=roads&minx=-0.1278&miny=51.5074&maxx=-0.0978&maxy=51.5274"
# Expect: {"invalidated_keys": 1}
# 5. Confirm the cache key is gone
redis-cli GET "cache:bbox:roads:-0.1278:51.5074:-0.0978:51.5274"
# Expect: (nil)
# 6. Next request re-populates from PostGIS
curl -s "http://localhost:8000/api/features?layer=roads&minx=-0.1278&miny=51.5074&maxx=-0.0978&maxy=51.5274"
# Expect: {"source": "db", ...}To verify tag Set TTL is set correctly:
redis-cli TTL "tag:bbox:roads:-1.0:51.0"
# Expect: a positive integer close to TAG_TTL (5400)Related
- Redis Caching for Spatial Queries — cache-aside architecture, key normalization, and async FastAPI middleware
- Query Plan Analysis & Index Tuning — use
EXPLAIN ANALYZEto identify which PostGIS operations benefit most from caching - Bounding Box & Spatial Index Queries — PostGIS
GISTindex setup andST_Intersectsquery patterns that feed the cache - Connection Pooling & PgBouncer Setup — reduce PostGIS connection pressure on cache misses
← Back to Redis Caching for Spatial Queries