Cache-first geocoding, and when to re-geocode
Most applications geocode the same address far more often than they need to. A geocode is a network call that costs money and takes tens of milliseconds, to answer a question whose answer changes roughly never. It belongs in a cache.
The interesting part is not the caching. It is knowing when the cached answer has stopped being good enough, which is a question about your data, not about the API.
Store the result, not just the coordinates
The common mistake is persisting lat and lon and discarding everything else.
Six months later someone asks "how many of these are actually rooftop matches?"
and there is no way to answer without re-geocoding the entire table.
Keep the fields that describe the quality of the answer, not only the answer:
CREATE TABLE geocode_cache (
address_key TEXT PRIMARY KEY,
input_address TEXT NOT NULL,
standardized TEXT,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
census_block TEXT,
fips TEXT,
apn TEXT,
match_code TEXT NOT NULL,
location_code TEXT,
point_source TEXT,
point_reason TEXT,
geocoded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
match_code, location_code, point_source and point_reason are the four
that let you audit later. point_source tells you which data layer produced the
coordinate; point_reason tells you why that one was chosen. Those two are the
difference between "we geocoded it" and "we can defend this coordinate".
Key the cache on a normalised string
def address_key(raw):
return " ".join(raw.split()).upper()
That is deliberately conservative. It collapses whitespace and case, and nothing else — no abbreviation expansion, no punctuation stripping. Aggressive normalisation feels clever and then merges two genuinely different addresses, which is a bug you will find much later and by accident.
If you want a canonical form, use the one the API gives you back:
standardizedAddress. Store it, but key on the raw normalised input, because
that is what you will have in hand next time you look something up.
Re-geocode on these, and only these
The address string changed. Obviously. Your cache key changes with it.
You got a weak match and want to try again. A SEGMENT result is
interpolated along a street. If coverage in that area improves, the same query
may resolve to a rooftop later. Re-running weak matches periodically is worth
it; re-running ROOFTOP matches is not.
SELECT input_address
FROM geocode_cache
WHERE location_code IN ('SEGMENT', 'PLACE')
OR match_code LIKE '%NO_MATCH%'
ORDER BY geocoded_at
LIMIT 5000;
You depend on Census geography and the vintage moved. Block GEOIDs are tied
to a decennial census. The blocks here are 2020. When 2030 blocks land, every
stored census_block refers to the previous vintage — that is a migration, not
a cache expiry, and you want to have kept geocoded_at so you can tell which
rows are which.
What is not on this list is time. A rooftop match on an address that still exists does not get worse because a year passed. Blanket TTL expiry on a geocode cache is the most common way to spend money re-deriving answers you already had.
The licensing question
Whether you may keep any of this depends on who geocoded it. Providers differ, and the difference is contractual rather than technical — some permit permanent storage, some restrict how long a coordinate may be cached, and at least one sells storable results as a separate, more expensive product.
That is worth checking against the vendor's own terms before you design a cache around it. We keep sourced, dated comparisons for the ones people ask about most: Google Maps, Mapbox, Geocodio, Smarty, and the Census Geocoder. Each one links the page we read the claim on and the date we read it, so you can re-check rather than take our word for it.
A minimal cache-first lookup
import requests
API = "https://weganar.com/api/v1/geocode"
def geocode(conn, api_key, raw_address):
key = address_key(raw_address)
row = conn.execute(
"SELECT * FROM geocode_cache WHERE address_key = %s", (key,)
).fetchone()
if row is not None:
return row
r = requests.post(
API,
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
json={"address": raw_address},
timeout=30,
)
r.raise_for_status()
g = r.json()
conn.execute(
"""INSERT INTO geocode_cache (
address_key, input_address, standardized, lat, lon,
census_block, fips, apn, match_code, location_code,
point_source, point_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (address_key) DO NOTHING""",
(key, raw_address, g.get("standardizedAddress"), g.get("lat"),
g.get("lon"), g.get("censusBlock"), g.get("fips"), g.get("apn"),
g["matchCode"], g.get("locationCode"), g.get("pointSource"),
g.get("pointReason")),
)
return g
One thing to notice: failures are cached too. A matchCode saying the address
did not resolve is a real answer, and re-asking the same unparseable string
every time a page loads is how a cache turns into a bill. Store it, and let the
weak-match sweep above decide when to try again.
Where to go next
- Geocode a large address list — the same idea at bulk scale
- Enrich a CSV with Census demographics
- Coverage by state — where weak matches are most likely