How to Cache Geocoding Results: TTL, Keys, and 90% Cost Reduction
Cache key design, TTL strategies, and cost math: how to cut geocoding spend by 90% without sacrificing freshness.
A million geocoding lookups per month at $0.50 per thousand is $500. With a 90% cache hit rate, that bill drops to $50. With a 95% hit rate it drops to $25. Caching is not a nice-to-have at the bottom of a performance checklist — it is the single largest lever in geocoding economics, and most teams leave it half-pulled.
This post is the practical version: cache key design, TTL strategies, the actual cost math at different hit rates, what to do about no-match results, how to invalidate without thrashing, and two ~50-line working cache layers (Node and Python) you can drop into a real pipeline. Every code sample compiles.
What actually changes about an address
Before designing a cache, decide what you are caching. The honest answer for geocoding is: addresses are stable. Street centroids drift on the order of meters per decade as municipalities re-survey. Rooftop coordinates for an existing building are stable for the life of the building — measured in decades. The actual churn is small and predictable:
| Change type | Frequency | Cache impact | |---|---|---| | New construction | ~1-2% of addresses per year, regionally clustered | New cache miss, then stable | | Demolition | <0.5% per year | Old result lingers; harmless until reverse-geocoded | | Renumbering / renaming | <0.1% per year | Rare, requires invalidation | | Boundary changes | ~once per decade per municipality | Affects city/postcode, not coords | | Postal code resort | ~0.5% per year (US) | Affects postal_code field, not lat/lng |
The practical conclusion: caching forward geocoding results indefinitely is almost always safe. The "freshness" panic that drives short TTLs is mostly imported from API caching for stock prices, weather, and inventory — none of which apply here. A coordinate that was correct in 2020 is overwhelmingly still correct in 2026.
The exception is if your downstream system uses fields beyond {lat, lng} — for instance, if you depend on the formatted_address matching the current USPS preferred form. In that case, treat the cache as an upstream dependency and refresh on a schedule (monthly, not daily).
Designing the cache key
A cache key has three jobs: uniquely identify the input, survive normalization differences ("123 main st" and "123 Main St." should hit the same entry), and not leak PII into your logs.
The shape that works in production is: normalize the address fields, lowercase, strip punctuation, then SHA-256 the composite. Pre-hashing gives you a fixed-length key (64 hex characters), no PII in your Redis dumps, and consistent ordering for cache audits.
// key.mjs
import { createHash } from 'node:crypto';
function normalize(s) {
if (!s) return '';
return String(s)
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '') // strip diacritics
.replace(/[.,;:'"!?()\[\]{}]/g, '') // strip punctuation
.replace(/\s+/g, ' ')
.trim();
}
export function cacheKey({ country, postcode, city, street, house_number }) {
const composite = [
normalize(country),
normalize(postcode),
normalize(city),
normalize(street),
normalize(house_number),
].join('|');
return 'geo:' + createHash('sha256').update(composite).digest('hex');
}
cacheKey({
country: 'US', postcode: '20500', city: 'Washington',
street: '1600 Pennsylvania Ave NW', house_number: '1600',
});
// -> 'geo:9b1d...e7f4'The Python equivalent is intentionally identical so the two languages produce the same key for the same input — letting you share a cache across services written in different stacks.
# key.py
import hashlib, re, unicodedata
def normalize(s):
if not s: return ''
s = unicodedata.normalize('NFKD', str(s).lower())
s = ''.join(c for c in s if not unicodedata.combining(c))
s = re.sub(r'[.,;:\'"!?()\[\]{}]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def cache_key(country, postcode, city, street, house_number):
parts = [normalize(x) for x in (country, postcode, city, street, house_number)]
composite = '|'.join(parts)
return 'geo:' + hashlib.sha256(composite.encode()).hexdigest()Two questions about this design come up often:
Why hash at all? Two reasons. First, raw addresses are PII in some jurisdictions (GDPR Article 4 treats them as personal data when linkable). A hashed cache key shows up in logs, error messages, and support tickets without leaking the address. Second, a SHA-256 collision in 2^128 inputs is roughly the probability of guessing a Bitcoin private key on the first try — you will hit other bugs first.
Why not include the country in the prefix? You can. geo:US:9b1d... makes per-country eviction trivial (SCAN MATCH geo:US:*) and is worth the extra characters if you expect to invalidate a country at a time after an Overture import. For deduplication-style work, see the related post on deduplicating geocoded addresses with stable keys.
TTL strategy
A single TTL across all caches is the wrong answer. Different layers have different tradeoffs between freshness, capacity, and hit rate. The pattern that survives audit:
| Layer | TTL | Why | |---|---|---| | Hot — in-process LRU | 5 minutes | Same request bursts hit the same address. Tiny capacity (~10K entries), microsecond reads, no network. | | Warm — Redis | 30 days | Cross-process cache for a single tenant or org. Big enough to hold a typical month of distinct addresses, small enough that staleness is bounded. | | Cold — Postgres / SQLite | Forever (with version tag) | Persistent ground truth. Survives restarts, deploys, Redis evictions. Invalidated by version bump, not TTL. |
The hot layer exists because in real traffic the same address often appears 5-20 times in a few seconds — a customer searches, then refines, then submits the form. An in-process LRU with 5-minute TTL absorbs that without ever hitting Redis.
The warm layer is your shared cache. 30 days is long enough that a typical address has time to be hit again before expiry, short enough that any actual address change shows up within a billing cycle. If you import an Overture release monthly, the 30-day TTL is naturally aligned to the data refresh.
The cold layer is forever-cached. Invalidation is by versioned key prefix — when you do something that changes results (new geocoder, new pre-processing, new fallback provider), you bump the prefix from geo:v3:... to geo:v4:... and the new code reads from the new namespace while the old data ages out.
# tiered.py
HOT_TTL = 300 # 5 min
WARM_TTL = 30 * 86400 # 30 days
# cold has no TTL; invalidate via key prefix `geo:v4:...`Cost math
Caching is the difference between a $50/month bill and a $5,000/month bill. The savings curve is non-linear in the right direction — every additional point of hit rate saves more than the previous one because you are amortizing setup costs over a smaller paid pool.
Here is the per-month spend at $0.50 per 1,000 lookups across realistic volumes and hit rates:
| Monthly lookups | 0% hit | 50% | 70% | 80% | 90% | 95% | |---:|---:|---:|---:|---:|---:|---:| | 50,000 | $25 | $13 | $7.50 | $5 | $2.50 | $1.25 | | 250,000 | $125 | $63 | $37.50 | $25 | $12.50 | $6.25 | | 1,000,000 | $500 | $250 | $150 | $100 | $50 | $25 | | 10,000,000 | $5,000 | $2,500 | $1,500 | $1,000 | $500 | $250 |
Two things to notice. First, the dollar savings going from 80% to 90% (50% reduction in spend) is the same as going from 0% to 50%. The "last mile" of caching is where the leverage is. Second, getting from 90% to 95% halves your bill again — which is why understanding *why* misses are happening pays off well past the point where caching feels "good enough."
A pipeline doing 1M lookups per month and paying $50 instead of $500 saves $5,400 a year. Two engineering days spent improving the cache hit rate from 80% to 95% pays for itself in the first month.
Hit rate is empirical — measure it
You cannot tune what you cannot measure. Instrument hit, miss, and total counts on every request, and emit them as Prometheus counters (or whatever metrics system you run).
// metrics.mjs
import client from 'prom-client';
export const cacheHits = new client.Counter({
name: 'geocode_cache_hits_total',
help: 'Geocoding cache hits',
labelNames: ['layer'],
});
export const cacheMisses = new client.Counter({
name: 'geocode_cache_misses_total',
help: 'Geocoding cache misses',
labelNames: ['layer'],
});The query that matters in your dashboard is a hit ratio over a rolling window:
sum(rate(geocode_cache_hits_total[5m]))
/
(sum(rate(geocode_cache_hits_total[5m])) + sum(rate(geocode_cache_misses_total[5m])))If you see hit rate dropping suddenly, you have either a new traffic pattern (a customer just uploaded a list of fresh addresses) or a cache problem (Redis evicting more aggressively, key normalization regression). The dashboard catches both before the bill does.
Negative caching
The single biggest mistake teams make is not caching no_match results. A typo in a customer's address book will be retried every time their nightly export runs — possibly forever. You pay the API cost, you eat the latency, and you get the same no_match answer.
Cache the negatives with a shorter TTL — 7 days is a reasonable default. Long enough that the same bad address from yesterday's batch does not get re-billed today, short enough that genuinely new construction has a path back to a successful lookup within a week.
async function geocodeWithCache(address) {
const key = cacheKey(address);
const cached = await redis.get(key);
if (cached) {
cacheHits.labels('redis').inc();
return JSON.parse(cached); // may be `{ no_match: true }`
}
cacheMisses.labels('redis').inc();
const result = await callGeocodingApi(address);
if (!result) {
await redis.setEx(key, 7 * 86400, JSON.stringify({ no_match: true }));
return null;
}
await redis.setEx(key, 30 * 86400, JSON.stringify(result));
return result;
}Do not negative-cache 5xx errors. A 500 from the upstream geocoder is not "this address has no answer" — it is "ask again later." Caching it punishes you for the upstream having a bad afternoon. For the right way to handle that retry path, see idempotent geocoding and safe retries.
Cache invalidation patterns
Cache invalidation is famously hard, but for geocoding it has only three real cases.
Case 1: Data refresh (monthly Overture imports, quarterly USPS updates). Bump the version prefix on your cold tier. New requests write under geo:v5:... and read from geo:v5:.... Old entries under geo:v4:... age out naturally. No FLUSHDB, no thundering herd, no service blip.
Case 2: Specific address known to have changed. Compute the key, delete it from Redis, let the next lookup re-populate. This is rare in practice — you only know about specific changes if a customer reports them.
Case 3: Provider switch (you swapped HERE for Google as a fallback, or added ML address pre-processing). Treat as Case 1: bump the version prefix. The behavior change *is* the version change.
What about stale-while-revalidate? Useful when freshness matters but latency more so. The pattern is: serve the cached result immediately, kick off a background re-fetch, write the new value back. For forward geocoding, the freshness gain rarely justifies the operational complexity — most teams should not bother. For reverse geocoding of GPS pings (where the answer can shift if a new building goes up), it has more value.
A working Node.js cache layer
About 50 lines, drop-in. Wraps any geocoding function with Redis caching, hit/miss instrumentation, and negative caching. Production-ready as written.
// cache.mjs
import { createClient } from 'redis';
import { createHash } from 'node:crypto';
import client from 'prom-client';
const hits = new client.Counter({ name: 'geo_cache_hits_total', help: '' });
const misses = new client.Counter({ name: 'geo_cache_misses_total', help: '' });
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const VERSION = 'v4';
const TTL_HIT = 30 * 86400; // 30 days
const TTL_MISS = 7 * 86400; // 7 days
function normalize(s) {
return String(s ?? '')
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[.,;:'"!?()\[\]{}]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function key(addr) {
const composite = [
normalize(addr.country), normalize(addr.postcode),
normalize(addr.city), normalize(addr.street),
normalize(addr.house_number),
].join('|');
return `geo:${VERSION}:${createHash('sha256').update(composite).digest('hex')}`;
}
export function withCache(geocodeFn) {
return async function cachedGeocode(addr) {
const k = key(addr);
const cached = await redis.get(k);
if (cached) {
hits.inc();
const parsed = JSON.parse(cached);
return parsed.no_match ? null : parsed;
}
misses.inc();
const result = await geocodeFn(addr);
if (!result) {
await redis.setEx(k, TTL_MISS, JSON.stringify({ no_match: true }));
return null;
}
await redis.setEx(k, TTL_HIT, JSON.stringify(result));
return result;
};
}Use it like this:
import { withCache } from './cache.mjs';
import { rawGeocode } from './client.mjs';
const geocode = withCache(rawGeocode);
const result = await geocode({
country: 'US', postcode: '20500', city: 'Washington',
street: 'Pennsylvania Ave NW', house_number: '1600',
});The Node geocoding client this wraps is from the Node.js geocoding tutorial. For backoff and rate-limit handling around the underlying API call, see rate limiting a geocoding pipeline.
A working Python cache layer
Same shape, redis-py. Tested on Python 3.11 with redis 5.0.
# cache.py
import hashlib, json, os, re, unicodedata
import redis
from prometheus_client import Counter
hits = Counter('geo_cache_hits_total', '')
misses = Counter('geo_cache_misses_total', '')
r = redis.from_url(os.environ['REDIS_URL'], decode_responses=True)
VERSION = 'v4'
TTL_HIT = 30 * 86400 # 30 days
TTL_MISS = 7 * 86400 # 7 days
def normalize(s):
if s is None: return ''
s = unicodedata.normalize('NFKD', str(s).lower())
s = ''.join(c for c in s if not unicodedata.combining(c))
s = re.sub(r'[.,;:\'"!?()\[\]{}]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def cache_key(addr):
parts = [normalize(addr.get(k)) for k in
('country', 'postcode', 'city', 'street', 'house_number')]
composite = '|'.join(parts)
digest = hashlib.sha256(composite.encode()).hexdigest()
return f'geo:{VERSION}:{digest}'
def with_cache(geocode_fn):
def cached(addr):
k = cache_key(addr)
cached_val = r.get(k)
if cached_val is not None:
hits.inc()
parsed = json.loads(cached_val)
return None if parsed.get('no_match') else parsed
misses.inc()
result = geocode_fn(addr)
if result is None:
r.setex(k, TTL_MISS, json.dumps({'no_match': True}))
return None
r.setex(k, TTL_HIT, json.dumps(result))
return result
return cachedBoth implementations share the same key derivation, so a Node service writing geo:v4:9b1d... will be read correctly by a Python worker reading the same key. That alignment is worth the discipline of keeping the normalization functions in sync — when you swap one out, swap both.
Frequently Asked Questions
How long should I cache geocoding results?
Forever for forward geocoding of street addresses, with a version-prefix invalidation strategy on the cold tier. 30 days for the warm Redis tier. 5 minutes for the in-process hot tier. The "freshness" instinct that says "1 hour" comes from caching things like stock prices and inventory — it does not apply to address-to-coordinate lookups, which are stable for years.
Should I cache no_match results?
Yes, with a shorter TTL — 7 days is a reasonable default. A bad address in a customer's CSV will be re-uploaded daily otherwise, costing you the API call every time for the same negative result. Do not cache 5xx errors; those are "try again later," not "no answer exists."
Does the geocoding API itself cache?
Most do, but it does not help your bill. The provider may serve your request from their internal cache faster, but they still bill you for it. The cache that saves money is the one in front of your call — same network, same code, same key. Do not rely on the upstream's cache to do your job.
In-process LRU vs Redis vs SQLite — which one?
In-process LRU for the hottest path (single-node, microsecond reads). Redis for cross-process and multi-instance setups (single-digit milliseconds, capacity in the millions). SQLite for embedded or single-machine workloads where you want persistence without standing up Redis. The full comparison with latency benchmarks and operational tradeoffs is in caching strategies for geocoding.
How do I handle PII in cache keys?
Pre-hash the normalized composite with SHA-256. The hashed key is a fixed-length 64-character hex string with no recoverable information about the address. It shows up in Redis dumps, error logs, and support tickets without leaking the address itself. For GDPR-scoped data, this is the difference between an audit problem and a routine engineering log.
What if my upstream provider standardizes addresses differently than my cache key?
You will see two cache misses for the same logical address — one from the customer's input, one from the standardized version. The fix is to normalize before keying, so "123 Main St." and "123 main street" produce the same key. The normalization function in the code above handles the common cases (case, punctuation, diacritics, whitespace). For region-specific quirks (German ß vs ss, Spanish ñ, Japanese romaji vs kanji) you may need a small lookup table on top.
Do hit rates above 95% mean I am under-caching?
It can mean that, but more often it means your traffic is repetitive — for instance, a B2B product where the same 50K customer addresses get re-validated nightly. Hit rates of 98-99% on that kind of traffic are normal and healthy. The signal of *under-caching* is a hit rate that drifts down over time without a corresponding traffic-pattern change. If you see that, check whether your TTL is shorter than your address re-use interval, or whether Redis is evicting under capacity pressure.
Closing
Caching is the highest-leverage thing you will do to a geocoding pipeline. Hash your composite key, normalize before hashing, tier your TTLs, cache no-matches, instrument hits and misses, and version-prefix for invalidation. Two days of work, an order-of-magnitude reduction in your bill, and a pipeline that keeps responding while the upstream has its bad afternoons.
For the storage tier itself, see Redis vs SQLite vs in-process. For the dedup-before-cache step that further compresses your address pool, see deduplicating geocoded addresses with stable keys. For the retry semantics around the cache miss path, see idempotent geocoding.
I.A. / CSV2GEO Creator
Related Articles
- Caching Strategies for Geocoding: Redis, SQLite, or in-Process
- Rate Limiting a Geocoding Pipeline: Token Bucket vs Leaky Bucket vs Sliding Window
- Idempotent Geocoding: Why and How to Make Calls Safe to Retry
- Node.js Geocoding API Tutorial: Concurrency, Retries, and CSV Streaming
- Deduplicating Geocoded Addresses: Stable Keys and Fuzzy Matching
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →