Deduplicating Store Records with Fuzzy Address Matching

This page solves one exact task: finding and merging duplicate location records — the same storefront arriving twice under different spellings, unit designators or trading names — using a blocking pass, a string-similarity score and a spatial distance test together.

Duplicates in a location dataset do specific damage. A competitor counted twice doubles the apparent saturation in the proximity index; an own store counted twice creates a cannibalization signal against itself; and both inflate the market coverage a planning team reports. None of that is visible on a map at national zoom.

Prerequisites

  • Python packages: rapidfuzz for string similarity, geopandas and shapely for the distance test, pandas for the frame. Install with pip install rapidfuzz geopandas shapely pandas.
  • Normalized, geocoded records. Components from address parsing and coordinates with a precision tier from batch geocoding. Deduplicating before geocoding finds only about a quarter of real duplicates.
  • A stable record identifier on every input row, so a merge can record what it absorbed.

Configuration and execution parameters

Parameter Value for this task Notes
block_on postcode + street token Reduces comparisons from quadratic to tractable
name_similarity_min 88 Token-set ratio; below this, names are treated as different
street_similarity_min 92 Street strings tolerate less variation than trade names
max_distance_m 25 Two points closer than this are candidates whatever the text
far_distance_m 200 Beyond this, text similarity alone never merges
tier_floor parcel Weak-tier points cannot support a distance-based merge
merge_policy survivor + aliases Never a hard delete
manual_review_band 0.55–0.80 Composite scores in this band go to a person

max_distance_m interacts with the precision tier in a way that catches teams out. Two postal-centroid points are always within twenty-five metres of each other when they share a postcode, so a distance test applied to weak-tier records merges every store in the postcode. The tier floor is what stops that, and it is not optional.

Blocking is what makes fuzzy matching affordable Comparing 41,000 competitor records pairwise is 840 million comparisons. Blocking on postcode reduces it to 2.1 million, and blocking on postcode plus the first street token to 190,000 — a run of seconds rather than hours, with a recall cost of under one per cent. Four thousand times fewer comparisons, one per cent less recall all pairs 840,000,000 comparisons · hours block on postcode 2,100,000 · about a minute postcode + street token 190,000 · seconds The recall cost is real and small: a duplicate pair whose postcodes genuinely differ — a boundary change, a typo in one of them — is never compared and so never found. Covering that gap needs a second block on a different key, typically the coordinate grid cell, run as a separate pass and unioned — which costs another 190,000 comparisons and finds most of the remainder.

Annotated implementation

The matcher below blocks, scores, and classifies each candidate pair into merge, review or reject. The composite score is deliberately simple and explainable, because every merge it makes will eventually be questioned by someone who owns one of the records.

python
from __future__ import annotations

import geopandas as gpd
import pandas as pd
from rapidfuzz import fuzz
from pyproj import CRS

METRIC_CRS = CRS.from_epsg(5070)
NAME_MIN, STREET_MIN = 88, 92
NEAR_M, FAR_M = 25.0, 200.0
STRONG_TIERS = {"rooftop", "parcel"}


def _blocks(df: pd.DataFrame) -> pd.Series:
    """Postcode plus the first street token — cheap and highly selective."""
    first_token = df["street"].str.split().str[0].fillna("")
    return df["postcode"].fillna("") + "|" + first_token


def candidate_pairs(df: pd.DataFrame) -> list[tuple[int, int]]:
    pairs: list[tuple[int, int]] = []
    for _, idx in df.groupby(_blocks(df)).groups.items():
        members = list(idx)
        for i in range(len(members)):
            for j in range(i + 1, len(members)):
                pairs.append((members[i], members[j]))
    return pairs


def score_pair(a: pd.Series, b: pd.Series, distance_m: float) -> float:
    """Composite in [0,1]. Text and geometry each veto; neither alone decides."""
    name = fuzz.token_set_ratio(a["name"], b["name"])
    street = fuzz.token_set_ratio(a["street"], b["street"])
    number_match = a["number"] == b["number"]

    strong_tiers = {a["tier"], b["tier"]} <= STRONG_TIERS
    if distance_m > FAR_M:
        return 0.0                                  # too far apart to be one place
    if not strong_tiers and distance_m <= NEAR_M:
        distance_component = 0.0                    # weak tiers cannot claim proximity
    else:
        distance_component = max(0.0, 1.0 - distance_m / FAR_M)

    text_component = (
        0.5 * min(name, 100) / 100 +
        0.5 * min(street, 100) / 100
    )
    bonus = 0.1 if number_match else 0.0
    if name < NAME_MIN and street < STREET_MIN:
        return min(distance_component, 0.5)         # geometry alone: review at best
    return min(1.0, 0.55 * text_component + 0.45 * distance_component + bonus)


def deduplicate(gdf: gpd.GeoDataFrame) -> pd.DataFrame:
    g = gdf.to_crs(METRIC_CRS)
    rows = []
    for i, j in candidate_pairs(g):
        a, b = g.loc[i], g.loc[j]
        d = a.geometry.distance(b.geometry)
        s = score_pair(a, b, d)
        if s == 0.0:
            continue
        rows.append({"left_id": a["record_id"], "right_id": b["record_id"],
                     "distance_m": round(d, 1), "score": round(s, 3),
                     "decision": "merge" if s >= 0.80
                     else ("review" if s >= 0.55 else "reject")})
    return pd.DataFrame(rows).sort_values("score", ascending=False)

The design decision worth defending is that neither signal can merge alone. Two records with identical names two hundred metres apart are two branches; two records at the same coordinate with unrelated names are two tenants in one building. Requiring both to agree is what keeps the merge rate honest, and it is why the score has vetoes rather than being a simple weighted sum.

Failure modes and debugging

Merging two tenants of one address. A shopping centre address hosts several businesses, and a distance test at twenty-five metres considers all of them identical. The name similarity is what separates them, which is why a weak name field — abbreviated, missing, or set to the centre’s name for every tenant — makes this failure likely. Where names are unreliable, add the trading category as a third signal and refuse to merge across categories.

Chained merges. Record A matches B, B matches C, but A and C are clearly different places. Naively transitive merging collapses all three. Treating merges as a graph and requiring the whole connected component to be mutually consistent — or simply capping components at pairs above a high threshold and sending larger ones to review — prevents a chain from swallowing a street.

Losing the loser’s data. A merge that keeps the survivor’s fields discards whatever the other record knew: an alternative trading name, an older identifier a downstream system still uses, a phone number. Writing the absorbed records as aliases on the survivor keeps the join key working for consumers that have not been updated, and makes the merge reversible.

Re-creating duplicates on the next import. If the merge exists only in the output table, the next import of the source produces both records again. The merge decision has to be persisted against the source identifiers, so the pipeline applies it automatically on subsequent runs — otherwise the review queue is Sisyphean.

The score distribution is bimodal, which is what makes thresholds workable Candidate pairs cluster near zero — different places that happened to share a block — and near one, where both text and geometry agree. The middle band from 0.55 to 0.80 holds 340 pairs out of 12,900 and is the only part a person needs to look at. Two clear populations and a small band that needs judgement reject · 11,940 pairs review · 340 merge · 620 Composite score from 0.0 on the left to 1.0 on the right. The thresholds sit in the trough, which is why moving them by a few hundredths changes very little — a property worth checking before trusting them.

Verification

  • Hand-label a sample of every band. A hundred pairs from merge, review and reject each, labelled by a person, gives precision and recall figures that mean something. Without them the thresholds are guesses that happen to produce a comfortable number of merges.
  • Confirm the merge is reversible. Pick a merged record, follow its aliases back to the source identifiers, and confirm both original rows can be reconstructed. If they cannot, the merge policy is destroying evidence.
  • Re-run the pipeline on the same input. The same merges must be produced, and previously-persisted decisions must be applied rather than re-derived. A dedupe pass that is not idempotent will oscillate as pairs are formed in different orders.
  • Count stores per market before and after. A market whose store count fell by a fifth is either a real duplicate cluster or an over-merge, and either way it is worth reading before publishing.
Measured against 300 hand-labelled pairs In the merge band, 291 of 300 labelled pairs were genuine duplicates, giving a precision of 0.97. Across the whole sample the matcher found 279 of the 296 true duplicates, a recall of 0.94, with the misses concentrated in pairs whose postcodes differ. Precision is what protects the estate; recall is what the review queue is for measure value where the errors are precision in the merge band 0.97 shopping-centre co-tenants recall over the whole sample 0.94 pairs with differing postcodes review-band yield 0.41 four in ten reviews are merges A review-band yield near a half is healthy: much higher and the merge threshold is too strict, much lower and people stop working the queue. Re-label annually — the source systems change, and so does what the matcher is up against.

Frequently Asked Questions

Should competitor records be deduplicated as aggressively as own stores?

More carefully, not more aggressively. An own-store duplicate is verifiable against an internal system of record, so a merge can be confirmed; a competitor duplicate is a judgement about a business you do not operate, made from a third-party feed. Over-merging competitors understates saturation and makes a market look more attractive than it is, which is the expensive direction to be wrong in. Keep the merge threshold higher for competitor data and let more pairs reach review.

What identifier should the surviving record carry?

The one downstream systems already use, which is usually the oldest internal identifier rather than the record with the best data. Merging into the newest record because it looks cleanest breaks every reference held elsewhere. Keep the survivor’s identifier stable, attach the absorbed identifiers as aliases, and resolve aliases on read so old references continue to work.

How does this interact with stores that genuinely close and reopen nearby?

Poorly, unless the records carry dates. A store that closed in March and a new one that opened in September two doors down are one location to a distance test and two facts to the business. Including an operating-period comparison in the match — refusing to merge records whose trading periods do not overlap — resolves it, and it is the same field that makes historical catchment analysis possible.

Can this run incrementally rather than over the whole estate?

Yes, and it should once the estate is large. New and changed records are compared against the existing set within their blocks, which is a small fraction of the full pass, and persisted merge decisions are re-applied rather than recomputed. The full sweep is then a periodic job that catches pairs the incremental pass could not see — typically those whose blocking key changed.

What does a good merge audit record look like?

One row per merge holding the survivor identifier, every absorbed identifier, the composite score, the distance, the two name strings as they were at merge time, the decision path — automatic or reviewed, and by whom — and the timestamp. That record is what lets a merge be explained to whoever owned one of the absorbed rows, reversed cleanly if it was wrong, and re-applied automatically on the next import. Teams that skip it discover the cost on the day someone asks why a competitor disappeared from a market, and the only available answer is that the matcher probably merged it into something.

← Back to Geocoding & Address Normalization