Deduplicating Geocoded Addresses: Stable Keys and Fuzzy Matching

Dedupe addresses before and after geocoding: stable hash keys, fuzzy matching, and when '123 Main St' equals '123 main street'.

| May 12, 2026
Deduplicating Geocoded Addresses: Stable Keys and Fuzzy Matching

You buy a marketing list of 50,000 addresses. You geocode it. The bill is $25. A week later you buy a "fresh" list from the same vendor. You geocode it. Bill is $25 again. You compare the lists: 18,000 of those 50,000 are the same addresses written differently. You just paid $9 to geocode the same buildings twice.

This post is the deduplication playbook. Stable hash keys for the easy 80%, fuzzy matching for the harder 15%, and an honest discussion of the 5% that won't dedupe cleanly no matter what you do. Working code in Python and Node. By the end you should be cutting 30–60% of geocoding spend on dirty input data with no quality loss.

The two dedup problems

There are actually two deduplication problems that people conflate:

  1. Pre-geocoding dedup. "123 Main St" and "123 main street" are the same address. Don't geocode both.
  2. Post-geocoding dedup. Two different inputs that resolved to the same lat/lng (or the same Overture ID) are the same building. Don't carry both rows in the downstream system.

The techniques are similar but the consequences differ. Pre-geocoding dedup saves API calls. Post-geocoding dedup keeps your data warehouse clean. Most teams do one and forget the other; the savings compound when you do both.

Pre-geocoding: stable hash keys

The key observation: after parsing and normalizing, the address has structured components. A hash of those components is a stable key. Same address → same hash, regardless of whitespace, casing, abbreviations.

Minimum viable pipeline:

# dedup.py
from postal.parser import parse_address
import hashlib

def stable_key(raw: str, country: str = 'US') -> str:
    components = {label: value for value, label in parse_address(raw)}
    parts = [
        components.get('house_number', ''),
        normalize_road(components.get('road', '')),
        normalize_postcode(components.get('postcode', ''), country),
        country.upper(),
    ]
    return hashlib.sha256('|'.join(parts).encode()).hexdigest()[:32]

def dedup(raws: list[str], country: str = 'US') -> dict:
    """Returns {key: [original_indices]}."""
    groups = {}
    for i, raw in enumerate(raws):
        k = stable_key(raw, country)
        groups.setdefault(k, []).append(i)
    return groups

Run on a real list:

addrs = [
    "123 Main St, Springfield, IL 62701",
    "123 main street, Springfield IL 62701-1234",
    "  123 Main Street, Springfield, IL  ",
    "456 Oak Ave, Chicago, IL 60601",
    "456 oak avenue, chicago illinois",
]

groups = dedup(addrs)
# {
#   'a1b2c3...': [0, 1, 2],   # all three are the same
#   'd4e5f6...': [3, 4],      # both Oak Ave
# }

# 5 inputs → 2 unique → 60% saving on geocoding cost
unique_count = len(groups)
saving_pct = 100 * (1 - unique_count / len(addrs))

What's in the key:

  • house_number, normalized road, normalized postcode, country.

What's NOT in the key:

  • City and state. Why? Because they're often missing or wrong on dirty input, and (house_number, road, postcode) is already unique-enough in 99.9% of cases. Including city raises the false-distinct rate (two records of the same address with one missing the city look different).
  • Unit/apartment. Different units in the same building geocode to the same coords; they're not different addresses for our purpose. Track unit separately if your downstream cares.
  • The original raw string. Hashing the raw means typos and whitespace produce different keys — defeats the point.

Match-rate considerations

The 32-character truncated SHA-256 has effectively zero collision risk at any reasonable batch size. Birthday paradox math: at 32 hex chars (128 bits effective), you'd need ~10^19 keys before a 50% collision chance. A million-row batch is 10^6.

What about hash *distribution*? Doesn't matter for dedup — we don't care about uniformity. We care about exactness: same input → same hash, period.

Post-geocoding: dedup by result

After geocoding, you have richer signals: lat/lng, Overture ID (if you're using csv2geo), formatted_address. These let you collapse rows that the pre-geocoding key didn't catch.

def post_geocode_key(result: dict) -> str:
    """If two results share this key, they're the same building."""
    overture_id = result.get('overture_id')
    if overture_id:
        return f'overture:{overture_id}'
    # Fallback: round to ~10m precision
    lat = round(result['location']['lat'], 5)
    lng = round(result['location']['lng'], 5)
    return f'coord:{lat},{lng}'

round(lat, 5) ≈ ~1.1 m at the equator. Two rooftop coords for the same building always agree to that precision; two adjacent buildings rarely fall within 1.1m of each other. False merge rate at this precision is well under 0.1% on US data.

If you're using overture_id (csv2geo returns this on every result), use it as the primary key. Two inputs that resolved to the same Overture ID are by definition the same building, regardless of how the inputs were spelled.

Fuzzy matching: when stable keys aren't enough

Stable keys handle the 80% case. The remaining 20% has problems stable keys can't fix:

  • One row has the postcode, the other doesn't.
  • One row has the city, the other has only the street.
  • The house number is misspelled ("321 Main" vs "32l Main" — that's a lowercase L).

For these, you need fuzzy matching. The standard tools:

Levenshtein on the road name

from rapidfuzz import fuzz

def road_similarity(a: str, b: str) -> int:
    """0–100 similarity score."""
    return fuzz.ratio(a.lower(), b.lower())

# Example
road_similarity("Main Street", "Main St")        # → 91
road_similarity("Pennsylvania Ave", "Penn Ave")  # → 88
road_similarity("Oak Drive", "Elm Drive")        # → 67

Threshold of ~85 catches typos and abbreviations without false-merging "Oak" and "Elm". Tune per dataset; B2B data tolerates a higher threshold (90+) than consumer data (~80).

Token-set ratio for word reordering

fuzz.token_set_ratio("123 Main St Brooklyn", "Main St 123, Brooklyn NY")  # → 95

Useful when the order of address components varies (international data, OCR'd documents).

Soundex / Metaphone for sound-alike streets

For cases where users typed addresses by ear ("Mclelan" vs "McLellan"):

from metaphone import doublemetaphone

doublemetaphone("Mclelan")     # → ('MKLLN', '')
doublemetaphone("McLellan")    # → ('MKLLN', '')
# Same metaphone code → same pronunciation → likely typo of the same name

The full pipeline puts these together as a fallback ladder:

def find_match(target: dict, existing: list[dict]) -> dict | None:
    """Find a row in `existing` that matches `target`, with fallbacks."""
    target_key = stable_key_from_components(target)

    # Tier 1: exact key match
    for row in existing:
        if row['_stable_key'] == target_key:
            return row

    # Tier 2: same postcode + high road similarity + same house_number
    if target.get('postcode') and target.get('house_number'):
        for row in existing:
            if (row.get('postcode') == target['postcode']
                and row.get('house_number') == target['house_number']
                and fuzz.ratio(row.get('road', ''), target.get('road', '')) > 85):
                return row

    # Tier 3: same city + same house_number + metaphone match on road
    if target.get('city') and target.get('house_number') and target.get('road'):
        target_meta = doublemetaphone(target['road'])
        for row in existing:
            if (row.get('city') == target['city']
                and row.get('house_number') == target['house_number']
                and doublemetaphone(row.get('road', '')) == target_meta):
                return row

    return None

Fuzzy matching is O(N) per lookup, so on large datasets you bucket by something cheap (postcode, house_number) before the fuzzy compare. Postgres trigram indexes (pg_trgm) work well here for warehouses with millions of rows.

The honest 5% that won't dedupe

Some addresses genuinely look like duplicates but aren't:

  • Subdivided buildings. "123 Main St" might be split into 123A, 123B, 123C — same building, three legal addresses. Treat unit-aware dedup as a different question.
  • PO boxes. "PO Box 100, Springfield IL" and "100 Main St, Springfield IL" are completely different mailing identities.
  • Deliberate aliases. Big buildings often have multiple street-facing addresses (a corner building on Main + 5th). Geocoder will return one of them; the other inputs hit it via fuzzy match — which is correct or not depending on your use case.

The pragmatic answer: don't try to dedupe these. Tag the addresses with low-confidence dedup flags and let the human downstream user decide. False merges erode trust in your data; false-distinct rows are tolerable.

Cost numbers from a real run

A B2B mailing list we ran preprocessing + dedup on:

| Stage | Rows | Action | |---|---|---| | Raw input | 50,000 | starting batch | | After pre-geocoding dedup | 31,200 | 38% saving — 18,800 dups removed | | Geocoded | 31,200 | API cost: $15.60 (vs $25 raw) | | After post-geocoding dedup (overture_id) | 28,400 | 9% additional saving — fuzzy matches that the stable key missed |

Total: 43% reduction in unique rows from raw input to fully deduped output. API cost cut by 38% directly; downstream warehouse rows reduced by 43%.

The "saved 38% on the API bill" math:

  • Before dedup: 50,000 rows × $0.0005/row = $25.00
  • After pre-geocoding dedup: 31,200 rows × $0.0005/row = $15.60
  • Saving: $9.40 per 50K-row batch

At enterprise volumes (millions of rows monthly), pre-geocoding dedup saves $thousands/year. The implementation is 30 lines of Python.

Cache integration

Dedup and caching are the same problem with different names. The dedup key IS the cache key. If you've followed How to Cache Geocoding Results and put a cache layer in front of your geocoder, dedup happens automatically: the second occurrence of the same input hashes to the same key, hits the cache, and never calls the API.

Two patterns to wire it up:

# Option A — explicit pre-geocoding dedup
def geocode_batch(raws: list[str]) -> dict[str, dict]:
    groups = dedup(raws)
    results = {}
    for key, indices in groups.items():
        result = geocode(raws[indices[0]])  # geocode one representative
        for i in indices:
            results[i] = result               # apply to all members
    return results

# Option B — implicit via cache
def geocode_batch(raws: list[str]) -> list[dict]:
    return [geocode_with_cache(r) for r in raws]   # second occurrence hits cache

Option A is faster on a single batch (one geocode call per group of duplicates). Option B is simpler and naturally handles the cross-batch case (today's batch + last week's batch dedup against each other through the persistent cache).

Idempotency falls out for free

If the same input always produces the same key, idempotency is free: a retried call hits the cache, returns the same result, no double-charge. The dedup key, the cache key, and the idempotency key are the same hash. One concept, three uses.

Frequently Asked Questions

Why hash structured components instead of the raw address string?

Because raw strings are noisy — "123 Main St" and "123 Main Street" produce different hashes but represent the same address. Parsing first into {street, house_number, city, postcode} and hashing those structured fields ensures the same logical address always hashes to the same key, regardless of surface variation.

What is the difference between pre- and post-geocoding dedup?

Pre-geocoding dedup uses a hash of the normalized input to skip API calls for exact-match duplicates. Post-geocoding dedup uses the geocoder's response (Overture ID, snapped lat/lng rounded to ~5 meters) to collapse rows where the input looked different but resolved to the same physical location. Pre catches ~70% of duplicates; post catches another ~25%.

How many duplicate calls do typical batch pipelines waste?

Around 40% of calls in unprocessed customer data are duplicates — same address in different surface forms, same lead exported twice, address mentioned across multiple lists. Without dedup you pay full price for every one. With dedup that is 40% of your monthly geocoding cost eliminated for the price of 30 lines of code.

Will aggressive dedup merge addresses that should not be merged?

Above 95% similarity, yes — which is why the rule is stop at 95%. Below that threshold, tag the merge decision as low-confidence so a human can review if precision matters (compliance, billing, routing). Most pipelines never need to go below 95% — the false-positive cost outweighs the additional dedup savings.

Can the dedup key be the same as the cache key and the idempotency key?

Yes — that is the entire point of stable keys. The hash of (country, postcode, normalized-street, house-number) doubles as the dedup key (skip the call), the cache key (return cached result), and the idempotency key (safe retry, no double-charge). One concept, three uses.

Summary

Three rules for cheap, clean dedup:

  1. Hash structured components, not raw strings. Parse first, normalize, then hash.
  2. Use the result for post-geocoding dedup. Overture ID or rounded coords collapse the rows that the pre-geocoding key missed.
  3. Stop at 95%. Past that, you're false-merging real distinctions. Tag low-confidence dedup decisions, escalate to a human if it matters.

A batch geocoding pipeline without dedup is a pipeline paying full price for ~40% of its calls to do nothing useful. The fix is 30 lines and pays for itself the first day.

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →