As part of the Caching, Deduplication & Spatial Indexing section, this page covers the half of caching that is easy to get wrong quietly: knowing when an entry has stopped being true. A geocoding cache with a hit rate of 95% and a correctness problem is worse than no cache at all, because the errors are consistent, plausible, and invisible to every metric that watches call volume.
Three separate events make an entry stale, and only one of them is a clock. Confusing them produces the two classic failure modes — a cache that keeps serving results derived under rules you have since changed, and a cache that expires perfectly good rooftop coordinates every thirty days for no reason.
Prerequisites
The Three Invalidation Triggers
The first row is the one that causes incidents, because its scope is total and its symptom is silence. Nothing fails when the normalisation logic changes; the cache simply starts answering questions that were asked under different rules, and the wrongness only surfaces when someone notices duplicate records or a coordinate on the wrong street.
Step 1 — Version the Key Prefix
A version segment costs one string concatenation and converts the most dangerous invalidation event into a routine one. When the rules change, the version increments, every read misses cleanly, and the old entries expire on their own schedule instead of being deleted.
from __future__ import annotations
import hashlib
SCHEMA_VERSION = 3 # shape of the cached value
NORM_VERSION = 7 # the normalisation rules that derive the key
def cache_key(canonical: str, provider: str) -> str:
"""Version-prefixed cache key. Bump a version to invalidate cleanly."""
digest = hashlib.blake2b(canonical.encode("utf-8"), digest_size=12).hexdigest()
return f"geo:v{SCHEMA_VERSION}:n{NORM_VERSION}:{provider}:{digest}"
Two versions rather than one is deliberate. Changing the stored value’s shape does not require re-deriving keys, and changing the key derivation does not require rewriting stored values — separating them means each change invalidates only what it actually affects.
Step 2 — Tier the TTL by Precision
A single TTL is a compromise between entries that never change and entries that were never good. The precision tier already recorded with each result is exactly the signal needed to set a sensible lifetime.
| Precision | TTL | Jitter | Reasoning |
|---|---|---|---|
| rooftop | 180 days | ±10% | a confirmed delivery point is stable for years |
| parcel | 90 days | ±10% | stable, though parcels are occasionally re-cut |
| street | 30 days | ±15% | a rooftop match may appear at the next refresh |
| locality | 7 days | ±20% | the weakest result and the likeliest to improve |
| negative | 12 hours | ±25% | a failure is far more likely than a success to be fixed |
The negative row is frequently missing altogether, and its absence is expensive: without it, every batch re-pays for the same unresolvable addresses, forever. Twelve hours is long enough to protect a nightly run and short enough that a data refresh is picked up promptly.
Step 3 — Revalidate Weak Entries in the Background
Waiting for a read to miss means a weak result is served until its TTL elapses even though a better answer became available weeks earlier. A small background job that re-resolves low-precision entries turns that into an upgrade path.
Rate-limit the revalidation job well below the main pipeline’s share. It is optimisation work, not delivery work, and a revalidation sweep that consumes the quota a nightly batch needs has made the system worse in exchange for slightly better coordinates.
Step 4 — Warm From Durable Storage, Not From Providers
A cold cache after a Redis restart is a cost event only if you rebuild it by calling providers. With a durable results table, warming is a scan and a pipelined write.
import json
import psycopg
import redis
WARM_SQL = """
SELECT cache_key, payload, precision_tier, fetched_at
FROM geocode_best
WHERE fetched_at > now() - interval '180 days'
"""
TTL_BY_TIER = {"rooftop": 180 * 86_400, "parcel": 90 * 86_400,
"street": 30 * 86_400, "locality": 7 * 86_400}
def warm(conn: psycopg.Connection, r: redis.Redis, chunk: int = 5_000) -> int:
"""Repopulate the cache from durable storage. No provider calls."""
written = 0
with conn.cursor(name="warm") as cur: # server-side cursor, bounded memory
cur.itersize = chunk
pipe = r.pipeline(transaction=False)
for i, (key, payload, tier, _fetched) in enumerate(cur, start=1):
pipe.set(key, json.dumps(payload), ex=TTL_BY_TIER.get(tier, 7 * 86_400))
if i % chunk == 0:
pipe.execute()
written += chunk
pipe.execute()
return written
A named cursor keeps memory flat regardless of table size, and a non-transactional pipeline keeps the round trips down. On a few million entries this completes in minutes and costs nothing — compared with a day of paid re-resolution if the cache is allowed to refill from misses.
Measuring Freshness
Hit rate is the metric everyone tracks and it says nothing about correctness. Two more numbers close the gap: the age distribution of entries actually served, and the share of served entries below parcel precision.
An age distribution that is drifting older means revalidation is not keeping up or the TTLs are too long for how quickly your data changes. A rising share of low-precision entries means the cache is accumulating weak answers — which is exactly what the revalidation job exists to prevent, and a good signal that it has stopped running.
Both are cheap: sample one in a thousand cache reads and record the entry’s fetched_at and
tier. That sampling rate is enough to see the distribution and small enough to add no
measurable latency to the read path.
Freshness Is Not the Same as Correctness
It is worth separating two ideas that the word “stale” runs together. An entry can be old and perfectly correct, or fresh and wrong. Age is a proxy for the probability that a better answer exists, not evidence that the stored one is bad — and treating the two as identical produces both of the classic mistakes.
The first is expiring good data. A rooftop coordinate for an established address is as true after two years as it was on the day it was resolved, and a thirty-day TTL applied to it buys nothing but a recurring bill. The second is trusting fresh data. A locality-level fallback resolved this morning is a weak answer regardless of its age, and no TTL policy improves it.
The practical consequence is that freshness policy should be driven by the precision tier first and the clock second, which is exactly the tiering in the table above. It also means the metric worth watching is not average entry age but the age distribution within each tier — a rooftop tier drifting older is fine, and a locality tier drifting older means the revalidation job has stopped doing its job.
There is a third case worth naming because it defeats both signals: an address that was correct and has genuinely changed, through renumbering or redevelopment. No cache policy detects that from the inside. It surfaces through delivery failures, and the right response is a targeted invalidation of the affected postcode rather than a change to the global TTL.
One organisational note follows from all of this: the freshness policy belongs in one module, expressed as data, and not scattered across the call sites that write to the cache. A single table mapping precision tier to TTL and jitter is auditable, testable and easy to change deliberately, whereas the same numbers inlined at four write sites will drift apart within a quarter and nobody will notice until the ages look strange.
Edge Cases
A key change that is not a logic change. Adding a field to the value shape does not require re-deriving keys, and bumping the wrong version wastes a full re-resolution cycle. Keep the two versions genuinely independent and change only the one that moved.
Address text that changes for the same record. A customer correcting their address produces a new canonical key, so the old entry is simply orphaned rather than wrong. Let it expire; deleting it costs a round trip and buys nothing.
Providers that improve silently. Coverage improvements are not announced. The revalidation job is what converts them into better coordinates, and without it a pipeline can sit on locality-level fallbacks for years after rooftop data became available.
Multi-region caches drifting apart. Two regional Redis instances warmed at different times will serve different answers for the same key. Warm both from the same durable table and include the region in your freshness metrics so the drift is visible.
Negative entries hiding a fixed problem. A twelve-hour negative TTL is a compromise; if a large batch of addresses becomes resolvable after a reference update, waiting half a day is avoidable. Purge negatives explicitly after a known data refresh — it is a small, well-scoped delete rather than a flush.
Keeping it as data also means the policy can be printed in a runbook, which is where it is actually needed during an incident.
Freshness by Tier, Not in Aggregate
Troubleshooting
Hit rate collapses after a deploy. A version was bumped, deliberately or otherwise. Check the key prefix in a sampled key against the constants in the release.
Spend rises with a flat hit rate. Negatives are not being cached, so the misses are the same addresses every night. The negative-entry count is the metric that shows this immediately.
Coordinates get worse over time. Revalidation is replacing entries without comparing tiers. The guard in step 3 is the fix, and the damage already done needs a re-resolution of the affected tier.
FAQ
Should a geocoding cache ever be flushed?
Almost never. A flush sends every subsequent lookup to a provider at full price, all at once, which is both expensive and likely to hit rate limits. Bump the version in the key prefix instead: new reads miss cleanly, old entries expire on their own schedule, and the cost is spread over the TTL rather than concentrated in one hour.
How long should a rooftop geocode be cached?
Months rather than days. A confirmed delivery point rarely moves, so the risk of serving a stale rooftop coordinate is low and the cost of re-resolving it is real. Six months with jitter is a reasonable default; locality-level fallbacks deserve days, because they are the entries most likely to improve.
What actually invalidates a cached geocode?
Three things: a change to how the key is derived, which invalidates everything at once; a refresh of provider or reference data, which invalidates selectively and usually improves weak results; and the passage of time, which is the weakest signal of the three and the one most pipelines over-rely on.
Is it safe to cache a failed lookup?
Yes, and it is usually necessary — otherwise every batch re-pays for the same unresolvable addresses. Cache negatives with a much shorter TTL than positives, typically hours rather than months, because a failure is far more likely than a success to be fixed by the next data refresh.
How do I warm a cache without calling providers?
Rebuild it from the durable results table. Every result ever obtained is stored there with its provenance, so a cold cache can be repopulated with a single scan and a pipelined write — no quota, no cost, and it completes in minutes rather than in a day of paid lookups.
Related
- Revalidating Stale Geocodes in the Background — the selection query, the tier guard and the rate budget.
- Warming a Geocoding Cache From Historical Orders — pre-populating a new cache from data you already hold.
- Versioning Cache Keys Across Normalisation Changes — migrating keys without a re-resolution bill.
- Redis and Postgres Caching Patterns — the two-tier design these rules operate on.
- Caching Geocoding Results With Redis TTL in Python — key construction and jittered TTLs in code.