Skip to main content

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

Run this on your own addresses

Start free with 5,000 geocodes a month, Census block included.

Get your API key — 150 free credits