Caching, Deduplication & Spatial Indexing

Make high-throughput geocoding pipelines fast and clean: cache results in Redis and Postgres, deduplicate addresses by canonical key, and index geocoded data with PostGIS.

A geocoding pipeline that calls a provider for every address it sees is slow, expensive, and quietly non-compliant. The same addresses recur constantly across batches; unindexed spatial tables force sequential scans over millions of rows; and near-duplicate address strings inflate row counts and corrupt every downstream aggregate. Once a pipeline moves past a few thousand records, throughput and data quality are governed less by how you call the routing engine and more by how you cache its results, deduplicate its inputs, and index its outputs.

This guide surveys the three disciplines that make high-volume geocoding fast and clean: caching geocoding results across Redis and Postgres, deduplicating addresses by a fuzzy canonical key, and designing spatial indexes so proximity and containment queries stay sub-second at scale. Each section frames the failure it prevents, compares the practical approaches, and shows production Python and SQL. It builds directly on the clean, tagged output produced by core address parsing and standardization.


Why Redundant Work Dominates Geocoding Cost

Three distinct forms of waste compound as a pipeline grows, and each has a different remedy.

  • Redundant API calls. Address data is heavily repetitive — customer records, delivery manifests, and CRM exports contain the same locations over and over. Re-geocoding an address you resolved yesterday burns quota and money for a result you already own, and many provider licenses require you to reuse a cached coordinate rather than re-query it.
  • Unindexed spatial queries. A SELECT ... WHERE ST_DWithin(geom, :point, 500) over an unindexed table of geocoded points is a full sequential scan. At a million rows it turns an interactive dashboard query into a multi-second stall, and it gets linearly worse as the table grows.
  • Duplicate addresses. "100 Main St", "100 Main Street", and "100 MAIN ST." are the same place but three rows. Left unresolved they triple geocoding spend, split analytics across phantom locations, and produce inflated counts that quietly mislead every report built on top of them.

The fix is a small subsystem that sits between the routing engine and the spatial database: a cache that answers repeat lookups without an API call, a deduplication stage that collapses variant spellings before they reach the provider, and a spatial index that keeps the resulting table queryable. The next section shows how these three pieces connect.


Pipeline Architecture Overview

The caching, deduplication, and indexing layer intercepts normalized addresses on their way to the geocoder and shapes the results on their way into storage. Deduplication runs first so the cache and the provider only ever see one canonical form of each address; the cache absorbs repeat lookups; and the spatial index makes the committed points queryable.

Caching, Deduplication and Spatial Indexing Data Flow A normalized address enters a deduplication stage that builds a canonical key. The key is looked up in a two-tier cache: Redis first, then a Postgres cache table. On a cache hit the result is returned directly. On a cache miss the geocoding provider is called and both cache tiers are backfilled. The resolved point is written to a spatial database where a GiST or BRIN index and geohash column keep proximity queries fast. Normalized Address Deduplication canonical key fuzzy collapse block by ZIP Two-Tier Cache 1. Redis · TTL 2. Postgres table key = hash(canon) hit → return miss ↓ provider Geocoding Provider on cache miss only backfill Spatial Indexing GiST · BRIN geohash column ST_DWithin ready Spatial Database PostGIS · queryable INPUT STAGE 1 STAGE 2 STAGE 3

Each stage is examined in detail below, starting with the methodology trade-offs and then the production implementations.


Core Methodology Survey

Caching, deduplication, and indexing each admit several implementations. The right combination depends on data volume, how repetitive the address stream is, and how much operational surface you can maintain.

Approach Best fit Read latency Maintenance Scale ceiling
Redis exact-match cache (hash key + TTL) Hot path, high repeat rate Sub-millisecond Low — eviction is automatic Bounded by RAM; shard for more
Postgres cache table / materialized view Durable system of record, analytics Low (indexed) Medium — refresh scheduling Very high — disk-bound
Canonical-key dedup (deterministic string) Collapsing trivial variants O(1) hash join Low Very high
Fuzzy dedup (RapidFuzz + blocking) Residual near-duplicates O(n) within block Medium — threshold tuning High with good blocking
PostGIS GiST index General point/polygon queries Logarithmic Low Millions of rows
PostGIS BRIN index Huge append-mostly, geo-ordered tables Low with clustering Very low — tiny index Hundreds of millions
Geohash prefix Proximity buckets without spatial types O(1) prefix match Low Very high

In practice, a production pipeline uses several of these together: a Redis tier in front of a Postgres cache, a two-phase deduplication that runs canonical-key collapse before fuzzy matching, and a GiST index (with an optional geohash column) on the final geocoded table. The sections below build each piece.


Stage 1: Deduplicating Addresses Before They Cost You

Deduplication belongs upstream of both the cache and the provider. If three spellings of one address reach the geocoder as three distinct strings, you pay three times and produce three cache entries that never share a hit. Collapsing them to one canonical form first is the highest-leverage change in the whole pipeline.

The reliable pattern is two-phase. First, generate a deterministic canonical key by normalizing case, expanding standard abbreviations, stripping punctuation and unit noise, and joining components in a fixed order. Exact matches on that key collapse the overwhelming majority of trivial duplicates in a single hash operation.

import re
import unicodedata
from typing import Dict

# Compile substitution patterns once at module level.
_PUNCT = re.compile(r"[.,#]+")
_MULTI_WS = re.compile(r"\s{2,}")
_UNIT = re.compile(r"\b(?:apt|apartment|unit|ste|suite|fl|floor|rm|room)\b.*$")

# USPS-style suffix and directional abbreviations (abridged).
_SUFFIX: Dict[str, str] = {
    "street": "st", "avenue": "ave", "boulevard": "blvd", "drive": "dr",
    "road": "rd", "lane": "ln", "court": "ct", "place": "pl",
}
_DIRECTION: Dict[str, str] = {
    "north": "n", "south": "s", "east": "e", "west": "w",
    "northeast": "ne", "northwest": "nw", "southeast": "se", "southwest": "sw",
}


def canonical_key(street: str, city: str, state: str, postal: str) -> str:
    """
    Build a deterministic, comparison-friendly key for one address.

    Case-folds, expands abbreviations to a single canonical form, drops unit
    designators and punctuation, and joins components in a fixed order. Two
    inputs that describe the same delivery point should map to one key.
    """
    street = unicodedata.normalize("NFKC", street).casefold()
    street = _UNIT.sub("", street)
    street = _PUNCT.sub("", street)
    tokens = [_SUFFIX.get(t, _DIRECTION.get(t, t)) for t in street.split()]
    street = _MULTI_WS.sub(" ", " ".join(tokens)).strip()
    city = _PUNCT.sub("", city.casefold()).strip()
    state = state.casefold().strip()
    postal = postal.split("-")[0].strip()  # collapse ZIP+4 to 5-digit
    return "|".join((street, city, state, postal))

The canonicalization logic leans directly on the parsing rules covered in regex patterns for US address parsing and the case-folding discipline from Unicode and character normalization in Python. Keep the abbreviation tables aligned with USPS Publication 28 so the canonical form matches the postal standard.

For a batch, vectorize the collapse with pandas so duplicates are grouped in one pass rather than row by row:

import pandas as pd


def collapse_duplicates(df: pd.DataFrame) -> pd.DataFrame:
    """
    Add a canonical key column and return one representative row per key.

    Expects columns: street, city, state, postal. Keeps the first occurrence
    of each canonical key and records how many raw rows collapsed into it.
    """
    df = df.copy()
    df["canon"] = [
        canonical_key(s, c, st, p)
        for s, c, st, p in zip(df["street"], df["city"], df["state"], df["postal"])
    ]
    counts = df.groupby("canon", sort=False).size().rename("dup_count")
    deduped = df.drop_duplicates("canon", keep="first").join(counts, on="canon")
    return deduped.reset_index(drop=True)

The residual cases — a transposed digit, a misspelled street name, an abbreviation your table missed — survive exact canonicalization. Those are handled by fuzzy address matching with RapidFuzz, which compares candidate pairs by similarity score. The critical performance rule is blocking: only compare addresses that share a blocking key (postal code, or a geohash prefix once coordinates exist), so you never run an O(n²) comparison across the whole table.


Stage 2: Caching Geocoding Results

Once addresses are deduplicated, the cache absorbs every repeat lookup. The standard design is a two-tier read-through cache: Redis on the hot path for sub-millisecond exact-match reads, and a Postgres table as the durable system of record behind it. The full patterns — key design, TTL policy, and refresh — live in Redis and Postgres caching patterns.

The cache key is a hash of the canonical form (never the raw string), namespaced by provider and a normalization schema version so a logic change invalidates old entries cleanly:

import hashlib
import json
from typing import Optional

import redis.asyncio as aioredis

_SCHEMA_VERSION = "v3"  # bump when canonicalization logic changes


def cache_key(canon: str, provider: str) -> str:
    """Namespaced, versioned SHA-256 key for a canonical address + provider."""
    digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
    return f"geo:{_SCHEMA_VERSION}:{provider}:{digest}"


class GeocodeCache:
    """
    Read-through cache. Redis is the hot tier; a Postgres table (queried via
    the injected `db_lookup`/`db_store` callables) is the durable tier.
    """

    def __init__(self, redis_url: str, default_ttl: int = 30 * 86400) -> None:
        self._redis = aioredis.from_url(redis_url)
        self._default_ttl = default_ttl  # seconds; cap to provider ToS

    async def get(self, canon: str, provider: str) -> Optional[dict]:
        """Return a cached result from Redis, or None on miss."""
        raw = await self._redis.get(cache_key(canon, provider))
        return json.loads(raw) if raw else None

    async def put(
        self, canon: str, provider: str, result: dict, ttl: Optional[int] = None
    ) -> None:
        """Store a result in Redis with a provider-appropriate TTL."""
        await self._redis.set(
            cache_key(canon, provider),
            json.dumps(result, separators=(",", ":")),
            ex=ttl or self._default_ttl,
        )

The TTL is not a performance knob — it is a compliance control. Some providers forbid caching results beyond a fixed window, so the maximum retention must be encoded per provider and enforced by expiry. This connects to the licensing discussion in API quota tracking and cost management: a cache hit is a saved API call, and a saved API call is saved quota and money.

For durable, queryable caching that also feeds analytics, a Postgres materialized view geocode cache persists resolved coordinates on disk and can be refreshed incrementally. A minimal cache table plus read-through query looks like this:

CREATE TABLE geocode_cache (
    canon        text        PRIMARY KEY,
    provider     text        NOT NULL,
    lon          double precision NOT NULL,
    lat          double precision NOT NULL,
    precision    text        NOT NULL,
    geom         geometry(Point, 4326)
                 GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326)) STORED,
    resolved_at  timestamptz NOT NULL DEFAULT now()
);

-- Durable read-through lookup: returns a row only if fresh enough.
SELECT lon, lat, precision
FROM   geocode_cache
WHERE  canon = :canon
  AND  resolved_at > now() - interval '30 days';

The complete read-through flow is: hash the canonical key, check Redis, fall back to the Postgres cache table on a Redis miss, and only call the provider when both are empty — then backfill both tiers with the fresh result. Short-lived, high-repeat batches see hit rates well above 40%, so most records never reach the provider at all.


Stage 3: Designing Spatial Indexes for Geocoded Data

Cached, deduplicated coordinates are only useful if you can query them by location. The moment you write ST_DWithin, ST_Contains, or a nearest-neighbour ORDER BY geom <-> :point, an unindexed table forces a sequential scan. Designing spatial indexes for geocoded data covers the full decision, but the core choice is between GiST, BRIN, and geohash prefixes.

A GiST index is the default for point data. It supports the full range of spatial operators and stays efficient for containment and nearest-neighbour queries:

-- Default choice for mixed proximity / containment workloads.
CREATE INDEX idx_geocode_geom_gist
    ON geocode_cache USING gist (geom);

-- KNN nearest-neighbour query the GiST index accelerates directly.
SELECT canon, lon, lat
FROM   geocode_cache
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)
LIMIT 10;

A BRIN index wins when the table is very large, append-mostly, and physically ordered by location — for example when records are ingested region by region. BRIN stores only per-block min/max summaries, so it is a tiny fraction of a GiST index’s size and almost free to maintain, at the cost of requiring good physical clustering:

-- Cluster physically by location first, then a tiny BRIN summarizes blocks.
CLUSTER geocode_cache USING idx_geocode_geom_gist;
CREATE INDEX idx_geocode_geom_brin
    ON geocode_cache USING brin (geom) WITH (pages_per_range = 64);

The full trade-off — index size, write cost, and query patterns — is worked through in choosing GiST vs BRIN index in PostGIS. As a rule: reach for GiST first, and only switch to BRIN when the table is large, append-heavy, and you can keep it clustered.

When you need proximity buckets without native spatial types — for example to block deduplication candidates or to shard a cache — a geohash encodes a coordinate as a short string whose shared prefixes imply nearby locations. Geohash encoding for address proximity search covers precision selection; a plain B-tree on the geohash column then answers proximity-bucket queries with an ordinary prefix match:

import pandas as pd
import pygeohash as pgh


def add_geohash(df: pd.DataFrame, precision: int = 7) -> pd.DataFrame:
    """
    Add a geohash column for proximity blocking and B-tree indexing.

    Precision 7 encodes roughly a 150 m cell, a good default for grouping
    candidate duplicates within a neighbourhood before fuzzy comparison.
    """
    df = df.copy()
    df["geohash"] = [
        pgh.encode(lat, lon, precision=precision)
        for lat, lon in zip(df["lat"], df["lon"])
    ]
    return df

That same geohash column closes the loop with Stage 1: it is the ideal blocking key for fuzzy deduplication, because two addresses can only be near-duplicates if they are physically close.


Edge Cases & Named Failure Modes

Stale Cache After a Street Rename

Municipalities rename streets and reassign postal boundaries. A permanently cached coordinate keeps serving the old location long after it changed. Bound this with a TTL sized to your staleness tolerance and a schema-versioned key, so a normalization or dataset change invalidates old entries instead of silently serving them. High-value records should be scheduled for periodic refresh rather than trusted forever.

Canonical Key Collisions

Aggressive canonicalization can map two genuinely different addresses to the same key — for example when unit designators are stripped and "5 Oak St Apt 1" and "5 Oak St Apt 2" collapse to the same delivery point. Decide deliberately whether unit-level resolution matters for your use case. If it does, keep the unit in the key; if you are geocoding to a building, dropping it is correct. Never let collision behaviour be accidental.

Antimeridian and Pole-Crossing Queries

Bounding-box and ST_DWithin queries near longitude ±180° or the poles can silently return wrong results if you compute distances in planar coordinates. Use the PostGIS geography type (or a projected CRS appropriate to the region) for distance math so calculations follow the curved surface, and test explicitly with coordinates that straddle the antimeridian.

Duplicate Explosion From Bad Blocking

Fuzzy deduplication without blocking is O(n²): a million-row table implies 10¹² comparisons. But a blocking key that is too coarse (state-level) recreates the same blow-up inside each block, while one that is too fine (full ZIP+4) hides real duplicates in separate blocks. Block on 5-digit postal code or a geohash prefix of precision 5–7, and monitor the largest block size as a guardrail.

Cache Stampede on Cold Start

When a cache is empty or freshly invalidated, a burst of concurrent workers can all miss the same key and call the provider simultaneously for one address. Guard hot keys with a short-lived lock (a Redis SET key NX sentinel) so the first worker resolves and backfills while the others wait or retry the cache, preventing a spike of redundant billed calls.


Validation & Quality Assurance

Caching and deduplication introduce their own correctness risks — a bad cache entry propagates to every future read, and an over-aggressive merge destroys real records. Apply three checks before trusting the layer.

Cache integrity. Periodically sample cached entries and re-geocode them against the provider, comparing coordinates within a tolerance (for example 50 m). Drift beyond tolerance signals a provider dataset change or a normalization bug and should invalidate the affected keys.

Deduplication precision and recall. Maintain a labelled set of known duplicate and known distinct address pairs. Measure how many true duplicates the canonical key plus fuzzy threshold actually collapse (recall) and how many distinct addresses are wrongly merged (false-merge rate). Tune the similarity threshold against this set rather than by intuition.

Spatial index correctness. After building or rebuilding an index, run representative proximity and containment queries with EXPLAIN ANALYZE and confirm the planner uses the index (an Index Scan or Bitmap Index Scan, not a Seq Scan). A query that silently falls back to a sequential scan — often because of a type mismatch between geometry and geography — defeats the whole exercise.

Stratify the deduplication test set across regions and address types so international formats and PO Box records, which behave differently under canonicalization, are represented.


Performance & Scaling

Technique Impact Notes
Redis exact-match cache 40%+ provider-call reduction on repeat batches SHA-256 of canonical key; TTL capped to provider ToS
Two-phase dedup (canonical then fuzzy) Removes 90%+ of duplicates before geocoding Canonical collapse is O(n); fuzzy runs only within blocks
Blocking by postal / geohash Turns O(n²) matching into near-linear Monitor max block size; precision-5–7 geohash is a good default
GiST index on geom Logarithmic proximity queries Default for mixed workloads; ~1 index entry per row
BRIN index (clustered table) Index size cut by orders of magnitude Requires physical clustering; ideal for append-mostly geo-ordered data
Batch upsert into Postgres cache Linear ingest scale-out Use INSERT ... ON CONFLICT (canon) DO UPDATE; commit in chunks
Parallel geocoding of cache misses Saturates provider concurrency budget See the throughput guide below

The remaining lever is concurrency on the cache-miss path. Deduplication and caching shrink the volume that reaches the provider, and optimizing batch geocoding throughput then processes that residual in parallel — for CPU-bound canonicalization and hashing, parallel geocoding with multiprocessing spreads the work across cores, while I/O-bound provider calls belong on the async path described in building async geocoding requests.


Troubleshooting: Common Failure Patterns

Cache Hit Rate Is Unexpectedly Low

If a repetitive dataset produces few cache hits, the canonical key is not deterministic. A common cause is non-deterministic ordering (dict iteration, locale-dependent case folding) or a raw string leaking into the key instead of the canonical form. Log the canonical key alongside the raw input for a sample and confirm that known-equivalent addresses produce byte-identical keys.

Spatial Query Still Does a Sequential Scan

EXPLAIN shows a Seq Scan despite an existing GiST index. Usually the query column and index column disagree — you indexed geometry but filtered on a geography cast, or the SRID differs. Ensure the predicate operates on the exact indexed expression, and rebuild statistics with ANALYZE so the planner has accurate row estimates.

Deduplication Merges Distinct Addresses

If reports show suspiciously few locations, the similarity threshold is too loose or the canonical key drops a discriminating component. Lower the fuzzy match acceptance, re-introduce the unit or secondary designator into the key where unit-level resolution matters, and audit merges against the labelled test set before applying them to production data.

Redis Memory Grows Without Bound

An ever-growing Redis footprint means entries are written without a TTL, or with a TTL longer than the working set justifies. Verify every SET passes an ex argument, configure a maxmemory policy such as allkeys-lru as a safety net, and separate long-lived durable data into the Postgres tier rather than holding it all in RAM.

Materialized View Refresh Locks Readers

A plain REFRESH MATERIALIZED VIEW takes an exclusive lock and blocks queries for its duration. Use REFRESH MATERIALIZED VIEW CONCURRENTLY (which requires a unique index on the view) so readers continue during the refresh, and schedule refreshes as incremental deltas keyed on resolved_at rather than full rebuilds.

Geohash Buckets Miss Nearby Neighbours

Two addresses a few metres apart can fall on opposite sides of a geohash cell boundary and land in different buckets. When using geohash prefixes for proximity search or blocking, always also check the eight neighbouring cells, not just the exact-prefix bucket, or a real duplicate straddling a boundary will be missed.


FAQ

Should I cache geocoding results in Redis or Postgres?

Use both in tiers. Redis serves the hot path with sub-millisecond exact-match lookups keyed on a hash of the normalized address, with a TTL that respects provider terms of service. Postgres is the durable system of record: it survives restarts, supports spatial queries, and can be exposed as a materialized view for analytics. A read-through pattern checks Redis first, falls back to Postgres, then calls the provider and backfills both layers.

How long can I legally cache geocoding results?

It depends entirely on the provider’s terms of service. Google Maps Platform prohibits caching most results beyond 30 days and forbids storing latitude/longitude for Places content indefinitely; OpenStreetMap-based services like Nominatim are far more permissive under ODbL with attribution. Always encode the maximum retention as a per-provider TTL in your cache layer so an expired entry forces a fresh lookup rather than serving stale, non-compliant data.

What spatial index type should I use for geocoded points?

For general nearest-neighbour and containment queries on point data, a PostGIS GiST index on the geometry or geography column is the default choice. Use a BRIN index instead when the table is very large, append-mostly, and physically ordered by location (for example ingested per region), because BRIN is dramatically smaller and cheaper to maintain. Geohash string prefixes are useful when you need index-friendly proximity buckets in a system without native spatial types.

How do I deduplicate addresses that are spelled slightly differently?

Generate a deterministic canonical key by normalizing case, expanding USPS abbreviations, stripping punctuation and unit designators, and concatenating the components in a fixed order. Exact matches on the canonical key collapse most trivial variants. For the residual near-duplicates that survive canonicalization, apply fuzzy matching with a library like RapidFuzz and a similarity threshold, blocking candidate comparisons by postal code or geohash so you never compare every row against every other row.

Does caching break when the underlying address data changes?

Yes, if you cache indefinitely. Streets are renamed, ZIP codes are reassigned, and providers improve their datasets. Bound this risk with a TTL sized to your tolerance for staleness and a versioned cache key that includes a normalization schema version. When you change the normalization logic, bump the version so old entries are ignored rather than silently served, and schedule an incremental refresh of high-value records.