Postgres Materialized View as a Durable Geocode Cache

Use a Postgres table as the durable geocode cache and a MATERIALIZED VIEW over it to serve the single best-precision result per address, upserting new results with INSERT ... ON CONFLICT DO UPDATE and rebuilding the view with REFRESH MATERIALIZED VIEW CONCURRENTLY so reads never block; this page details that durable tier within Redis and Postgres caching patterns.

Why a Table Plus a Materialized View

Redis is the fast tier but a volatile one. The durable cache — the record of every coordinate the pipeline has ever paid for — belongs in Postgres, where it survives restarts and evictions and stays queryable. Storing every provider response gives you provenance and lets you compare providers, but read paths want exactly one answer per address. A materialized view resolves that tension: the base table keeps every result, and the view precomputes the best-precision winner per cache key so lookups are a single indexed read.

Column Breakdown

Column Type Purpose
cache_key char(64) SHA-256 hex digest of normalized address + country; the join/lookup key
raw_input text Original address string, for audit and re-normalization
country_code char(2) ISO country namespace
provider text Which provider produced this row (enables best-of aggregation)
lat / lon double precision Resolved coordinates
precision text rooftop / range / centroid / postal
confidence real Provider confidence score, 0–1
result jsonb Full provider payload for downstream use
expires_at timestamptz When this row should be re-verified
updated_at timestamptz Last write, for sweeps and partitioning

Schema DDL

The base table is keyed on (cache_key, provider) so each provider can hold its own answer for the same address; the materialized view then picks the best one. A precision-rank helper keeps the “best” ordering explicit.

CREATE TABLE IF NOT EXISTS geocode_cache (
    cache_key    char(64)         NOT NULL,
    provider     text             NOT NULL,
    raw_input    text             NOT NULL,
    country_code char(2)          NOT NULL DEFAULT 'US',
    lat          double precision,
    lon          double precision,
    precision    text             NOT NULL DEFAULT 'range',
    confidence   real             NOT NULL DEFAULT 0.0,
    result       jsonb            NOT NULL,
    expires_at   timestamptz      NOT NULL DEFAULT now() + interval '30 days',
    updated_at   timestamptz      NOT NULL DEFAULT now(),
    PRIMARY KEY (cache_key, provider)
);

CREATE INDEX IF NOT EXISTS idx_geocode_cache_expires
    ON geocode_cache (expires_at);

-- Ordinal rank so the view can pick the highest precision deterministically.
CREATE OR REPLACE FUNCTION precision_rank(p text) RETURNS int
    LANGUAGE sql IMMUTABLE AS $$
    SELECT CASE p
        WHEN 'rooftop'  THEN 4
        WHEN 'range'    THEN 3
        WHEN 'centroid' THEN 2
        WHEN 'postal'   THEN 1
        ELSE 0
    END
$$;

Best-Precision Materialized View

DISTINCT ON (cache_key) with an ordering by precision rank then confidence yields exactly one winning row per address. Only non-expired rows are considered, so stale results drop out on the next refresh. The unique index on cache_key is what enables REFRESH ... CONCURRENTLY.

CREATE MATERIALIZED VIEW IF NOT EXISTS geocode_best AS
SELECT DISTINCT ON (cache_key)
    cache_key,
    provider,
    country_code,
    lat,
    lon,
    precision,
    confidence,
    result,
    expires_at
FROM geocode_cache
WHERE expires_at > now()
ORDER BY
    cache_key,
    precision_rank(precision) DESC,
    confidence DESC,
    updated_at DESC;

-- Required for REFRESH MATERIALIZED VIEW CONCURRENTLY.
CREATE UNIQUE INDEX IF NOT EXISTS idx_geocode_best_key
    ON geocode_best (cache_key);

Read paths hit geocode_best for a single-row answer:

SELECT lat, lon, precision, confidence
FROM geocode_best
WHERE cache_key = $1;

UPSERT on Write

Every provider result is upserted into the base table. ON CONFLICT (cache_key, provider) DO UPDATE overwrites a provider’s previous answer for the same address, refreshing coordinates, precision, and expiry in one atomic statement.

INSERT INTO geocode_cache
    (cache_key, provider, raw_input, country_code,
     lat, lon, precision, confidence, result, expires_at, updated_at)
VALUES
    ($1, $2, $3, $4, $5, $6, $7, $8, $9,
     now() + interval '30 days', now())
ON CONFLICT (cache_key, provider) DO UPDATE SET
    lat        = EXCLUDED.lat,
    lon        = EXCLUDED.lon,
    precision  = EXCLUDED.precision,
    confidence = EXCLUDED.confidence,
    result     = EXCLUDED.result,
    expires_at = EXCLUDED.expires_at,
    updated_at = now();

Refreshing the View Concurrently

A plain REFRESH MATERIALIZED VIEW takes an ACCESS EXCLUSIVE lock and blocks every reader until the rebuild finishes — unacceptable for a cache serving live lookups. CONCURRENTLY rebuilds in the background and swaps atomically, so reads keep working throughout. It requires the unique index created above and cannot run inside a transaction block.

REFRESH MATERIALIZED VIEW CONCURRENTLY geocode_best;

Refresh on a cadence that matches write volume: after each large batch load, or on a schedule (for example every 15 minutes) for a continuously-fed pipeline. CONCURRENTLY does more work than a plain refresh, so do not run it more often than your read-staleness tolerance requires.

Python Batch Upsert With psycopg

Row-at-a-time inserts waste round trips. executemany with psycopg batches the upsert, and a single REFRESH ... CONCURRENTLY follows the load. The cache key is derived from the same normalization used elsewhere in the pipeline; see Unicode and character normalization in Python.

from __future__ import annotations

import hashlib
import json
from typing import Iterable, Sequence

import psycopg

_UPSERT = """
INSERT INTO geocode_cache
    (cache_key, provider, raw_input, country_code,
     lat, lon, precision, confidence, result, expires_at, updated_at)
VALUES
    (%s, %s, %s, %s, %s, %s, %s, %s, %s,
     now() + interval '30 days', now())
ON CONFLICT (cache_key, provider) DO UPDATE SET
    lat        = EXCLUDED.lat,
    lon        = EXCLUDED.lon,
    precision  = EXCLUDED.precision,
    confidence = EXCLUDED.confidence,
    result     = EXCLUDED.result,
    expires_at = EXCLUDED.expires_at,
    updated_at = now();
"""


def cache_key(normalized: str, country_code: str = "US") -> str:
    """SHA-256 hex digest of the normalized address plus country code."""
    pre = f"{normalized}|{country_code.upper()}"
    return hashlib.sha256(pre.encode("utf-8")).hexdigest()


def batch_upsert(
    dsn: str,
    rows: Iterable[dict],
    refresh: bool = True,
) -> int:
    """Upsert geocode results and optionally refresh the best-precision view.

    Each row dict needs: normalized, provider, raw_input, country_code,
    lat, lon, precision, confidence, result.
    Returns the number of rows submitted.
    """
    params: list[Sequence[object]] = []
    for r in rows:
        params.append((
            cache_key(r["normalized"], r.get("country_code", "US")),
            r["provider"],
            r["raw_input"],
            r.get("country_code", "US"),
            r["lat"],
            r["lon"],
            r.get("precision", "range"),
            float(r.get("confidence", 0.0)),
            json.dumps(r["result"]),
        ))

    with psycopg.connect(dsn) as conn:
        with conn.cursor() as cur:
            cur.executemany(_UPSERT, params)
        conn.commit()
        if refresh:
            # CONCURRENTLY cannot run inside a transaction block.
            conn.autocommit = True
            with conn.cursor() as cur:
                cur.execute(
                    "REFRESH MATERIALIZED VIEW CONCURRENTLY geocode_best"
                )
    return len(params)

Vectorized pandas variant

When results arrive as a DataFrame, project the columns, derive keys vectorized, and hand the records to the same batch upsert.

from __future__ import annotations

import pandas as pd


def upsert_dataframe(dsn: str, df: pd.DataFrame) -> int:
    """Upsert a DataFrame of geocode results into the durable cache.

    Expects columns: normalized, provider, raw_input, country_code,
    lat, lon, precision, confidence, result (dict).
    """
    df = df.copy()
    df["country_code"] = df["country_code"].fillna("US")
    rows = df.to_dict(orient="records")
    return batch_upsert(dsn, rows, refresh=True)

Edge Cases

REFRESH CONCURRENTLY fails without a unique index

CONCURRENTLY requires a unique index on the materialized view (here idx_geocode_best_key on cache_key). Without it, Postgres raises cannot refresh materialized view "geocode_best" concurrently. Create the unique index before the first concurrent refresh, and ensure the view’s DISTINCT ON genuinely produces one row per cache_key or the index build itself will fail on duplicates.

The materialized view goes stale between refreshes

The view is a snapshot; a result upserted after the last refresh is invisible to geocode_best until the next REFRESH. For lookups that must be strictly current, read the base table’s best row directly with the same DISTINCT ON ordering, and reserve the view for high-volume read paths that tolerate minutes of staleness. This is the classic freshness-versus-throughput trade the two-tier design manages, alongside the volatile Redis TTL layer.

Expired rows linger in the base table

expires_at filters expired rows out of the view but does not delete them from the base table. Sweep periodically with a batched DELETE FROM geocode_cache WHERE expires_at < now() run off-peak, or partition by updated_at month and drop old partitions — cheaper than row-by-row deletes at scale. The idx_geocode_cache_expires index keeps both the view filter and the sweep fast.

Integration Note

This durable Postgres tier is the system of record in Redis and Postgres caching patterns: it backfills the hot Redis cache after an eviction or restart so a paid result is never lost to volatility. Because the base table retains every provider’s answer, it also feeds provider benchmarking — the best-precision view is exactly the ground truth used when comparing geocoding accuracy across providers.