Geocoding Cache Invalidation and Freshness

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

Three triggers, three scopes, three responses A three-row comparison. A key logic change invalidates every entry instantly and is handled by a version prefix. A reference data refresh invalidates selectively and is handled by background revalidation of weak entries. Elapsed time invalidates gradually and is handled by a precision-tiered jittered TTL. Trigger Scope Correct response key logic changed a normalisation edit every entry, at once bump the version prefix; old keys expire quietly never flush — the cost lands in one hour instead of a month reference data refreshed provider or postal update selective — weak entries background revalidation of low-precision results rooftop entries almost never change; leave them alone time elapsed the weakest signal gradual, per entry precision-tiered TTL with jitter on every write without jitter, a batch expires in one burst

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.

Background revalidation that only ever improves an entry A loop selects cached entries whose precision is below parcel level and whose age exceeds a threshold. Each is re-resolved through the normal provider chain. The new result replaces the stored one only when its precision tier is equal or better, so a temporary provider degradation cannot downgrade a good cached coordinate. select candidates precision < parcel, age > 14 d re-resolve normal chain, low priority compare tiers new ≥ stored? yes → replace no → keep old The tier comparison is what makes revalidation safe. Without it, a provider having a bad hour quietly downgrades good rooftop coordinates to street-level ones, and the damage is invisible until the next accuracy report. Run it at low priority: this work is never urgent and must not compete with the main batch for quota.

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

Two tiers, two readings of the same drift Two age distributions for served cache entries. The rooftop tier skews old and that is acceptable because those answers do not change. The locality tier also skews old, which is a problem because those are the entries most likely to have improved and indicates the revalidation job is not keeping up. Age of entries actually served, by tier rooftop skews old — correct; a confirmed delivery point does not move locality also skews old — a problem; these are the entries most likely to have improved One aggregate age number would have shown a single unremarkable distribution and hidden the second row entirely.

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.