Address Geocoding & Parcel Matching

Permit records arrive with an address. Zoning lives on a parcel. Nothing in either dataset carries the other’s identifier, so every question worth asking — has this parcel had a permit?, which parcels does this rezone ordinance actually cover?, did the setback we computed apply to the lot the inspector visited? — depends on a join that neither publisher designed for. That join is where a surprising amount of municipal automation quietly fails: not with an exception, but with a permit attached to the neighbouring lot, a condominium tower resolved to one of its 140 units, or a rural parcel matched to a mailing address six miles away. This topic covers doing that join defensibly, as part of the Automated Feed Ingestion & GIS Data Parsing area, and it produces the same kind of graded, recorded confidence that attribute normalization rules produce for codes.

Prerequisites and operational context jump to heading

Address matching is only meaningful once three inputs are in place, and the quality of the join is bounded by the weakest of them.

Positional error by geocode result kind Lollipop chart of typical positional error in metres for five kinds of geocode result. A county address point is under 5 metres. A parcel centroid from an exact text match is 0 metres by construction. A rooftop geocode is about 12 metres. A street-centreline interpolation is about 70 metres. A ZIP or place centroid is about 2 400 metres. A dashed rule at 30 metres marks the point beyond which a result can no longer confirm which parcel was meant, since it exceeds a typical urban lot width. Positional error by geocode result kind 0 1000 2000 3000 30 m — wider than a typical urban lot county address point 5 m parcel centroid (exact text match) 0 m rooftop geocode 12 m street-centreline interpolation 70 m ZIP or place centroid 2400 m Typical positional error (metres)
Thirty metres is roughly an urban lot width, so anything above the rule cannot establish which parcel was meant — it can only generate a candidate. Interpolated results are the trap: they look exactly like precise ones in the response, which is why the result kind field has to survive ingestion rather than being discarded with the rest of the envelope.

You need an authoritative parcel layer with stable identifiers, already reprojected through the CRS alignment strategies gate. A geocode is a coordinate, and a coordinate is only useful against geometry whose projection you can prove; matching a WGS84 geocode against a state plane parcel layer without a transform produces a point in the Gulf of Mexico, and matching it after a wrong transform produces a point two lots over, which is far worse because it looks plausible.

You need a site-address dataset if one exists, separate from the parcel layer. Many counties publish an address-point layer where each point has already been assigned to a parcel by the assessor. When it exists, it is better than any geocoder you could run, because it encodes local knowledge that no general-purpose geocoder has. Checking for one before building a matching pipeline is the highest-value ten minutes in this whole area.

And you need a normalisation function you control. 123 N. Main St. Apt 4B and 123 North Main Street #4B are the same address; 123 N Main St and 123 S Main St are not, and differ by one character. Off-the-shelf fuzzy matching gets the second pair wrong in exactly the way that matters, which is why normalisation must be explicit and directional rather than delegated to a similarity score.

Architecture: candidate generation, then spatial confirmation jump to heading

The mistake that produces most bad matches is treating this as a single lookup — text in, parcel out. The reliable shape is two-phase: generate candidates cheaply from text, then confirm one of them spatially, and record which phase decided.

Candidate generation and spatial confirmation as separate phases A two-phase matching pipeline. A raw permit address is normalised, then three candidate sources are tried in order of authority: the county address-point layer, an exact parse against the parcel address field, and a geocoder. Each produces zero or more candidate parcels with a text score. The candidates then pass into a spatial confirmation phase which tests whether the candidate geometry contains the geocoded point, how far the point is from the parcel centroid, and whether exactly one candidate survives. The output carries a confidence tier: confirmed when text and geometry agree, probable when only one of them does, ambiguous when several candidates survive, and unmatched when none do. Ambiguous and unmatched results are routed to review rather than resolved by picking the closest. Phase 1 — candidate generation (text) normalise address directionals · unit · suffix 1 · county address points already parcel-assigned 2 · parcel address field exact match after normalising 3 · geocoder last resort, not first candidate set 0..n parcels + text score Phase 2 — spatial confirmation (geometry) confirm one candidate point inside the polygon? distance to centroid plausible? exactly one survivor? confirmed text and geometry agree — usable everywhere probable one of the two agrees — usable with the flag ambiguous several survive — to review, never guessed unmatched none survive — recorded, not dropped The order of the three candidate sources is the whole design: a county address-point layer encodes assessor knowledge that no geocoder has, so a geocoder is the fallback rather than the entry point. Four outcomes, not two: "ambiguous" and "unmatched" are results to record, not failures to resolve by guessing.
Two phases, four outcomes. Collapsing this into a single text lookup is what produces a permit on the neighbouring lot: text alone cannot distinguish 123 N Main from 123 S Main when only one of them exists in the parcel layer, and geometry alone cannot tell which of a condominium's 140 units was meant.

The reason the phases must stay separate is that they fail independently and for different reasons. A text match can be perfect while the geometry is absent (a newly platted lot with no polygon yet). A spatial match can be perfect while the text is nonsense (a geocoder interpolating along a street centreline lands inside the right parcel by luck). Recording which phase produced the answer is what lets a downstream consumer decide whether the match is good enough for their purpose, and it is why the output is a tier rather than a boolean.

Why interpolated geocodes are dangerous rather than merely imprecise jump to heading

A general geocoder that cannot find an exact address will often interpolate: it knows the 100 block of Main Street runs from one intersection to another, so it places number 123 proportionally along that line. The result is a coordinate that looks like every other coordinate. It carries no visible marker saying “this is a guess along a line,” and it will sit inside some parcel — frequently the wrong one, and systematically the wrong one on blocks with uneven lot widths.

Geocode result kind Typical positional error Lands in the right parcel? Safe for a zoning decision?
County address point under 5 m almost always yes
Parcel centroid from an exact text match 0 m by construction yes yes
Rooftop geocode 5–20 m usually with confirmation
Street-centreline interpolation 20–120 m often not no
ZIP or place centroid kilometres no never

The operational rule that follows is short: never let an interpolated or place-level geocode confirm a match on its own. It can generate a candidate; it cannot be the evidence. Most geocoding APIs report the result kind — match_type, location_type, or similar — and discarding that field on ingest throws away the one thing that distinguishes a five-metre answer from a five-kilometre one.

Production implementation jump to heading

The matcher below implements the two phases and the four outcomes. It assumes parcels are already in a projected CRS with metre units, because every distance test in it is meaningless otherwise.

import logging
from dataclasses import dataclass
from enum import Enum

import geopandas as gpd
from shapely.geometry import Point

log = logging.getLogger(__name__)

# A geocode whose kind is not in this set may generate a candidate but may never
# confirm one on its own — an interpolated point lands in the wrong parcel often
# enough that it cannot be treated as evidence.
CONFIRMING_KINDS = {"address_point", "rooftop", "parcel_centroid"}

MAX_CENTROID_DISTANCE_M = 400.0   # beyond this, a "match" is almost always wrong


class Tier(str, Enum):
    CONFIRMED = "confirmed"
    PROBABLE = "probable"
    AMBIGUOUS = "ambiguous"
    UNMATCHED = "unmatched"


@dataclass(frozen=True)
class Match:
    parcel_id: str | None
    tier: Tier
    evidence: str
    text_score: float
    distance_m: float | None
    candidates: int


def normalise(raw: str) -> str:
    """Explicit, directional normalisation. Deliberately not fuzzy: collapsing
    N/S or E/W here would make two different addresses look identical."""
    s = " ".join(raw.upper().split())
    for a, b in (
        (" NORTH ", " N "), (" SOUTH ", " S "), (" EAST ", " E "), (" WEST ", " W "),
        (" STREET", " ST"), (" AVENUE", " AVE"), (" ROAD", " RD"), (" DRIVE", " DR"),
        (" BOULEVARD", " BLVD"), (" LANE", " LN"), (" COURT", " CT"),
    ):
        s = s.replace(a, b)
    # Unit designators are removed from the street key and kept separately, so a
    # tower's 140 units all normalise to one street address rather than 140.
    for token in (" APT ", " UNIT ", " STE ", " #"):
        if token in s:
            s = s.split(token)[0].strip()
    return s.rstrip(".").strip()


def match_address(raw_address, parcels: gpd.GeoDataFrame, address_points, geocoder) -> Match:
    key = normalise(raw_address)

    # ---- phase 1: candidates, in order of authority -------------------------
    candidates, evidence, text_score = [], "none", 0.0

    hit = address_points.get(key)                      # the assessor's own assignment
    if hit:
        candidates, evidence, text_score = [hit.parcel_id], "county_address_point", 1.0
    else:
        exact = parcels.index[parcels["addr_key"] == key].tolist()
        if exact:
            candidates, evidence, text_score = exact, "parcel_address_field", 1.0
        else:
            geo = geocoder.geocode(raw_address)
            if geo is None:
                return Match(None, Tier.UNMATCHED, "geocoder_no_result", 0.0, None, 0)
            pt = Point(geo.x, geo.y)
            containing = parcels.index[parcels.contains(pt)].tolist()
            candidates = containing
            evidence = f"geocode_{geo.kind}"
            text_score = geo.score

    if not candidates:
        return Match(None, Tier.UNMATCHED, evidence, text_score, None, 0)

    if len(candidates) > 1:
        # Several parcels are plausible. This is the condominium-plat case and the
        # shared-driveway case; both need a rule, and neither may be guessed.
        return Match(None, Tier.AMBIGUOUS, evidence, text_score, None, len(candidates))

    # ---- phase 2: spatial confirmation -------------------------------------
    parcel_id = candidates[0]
    parcel = parcels.loc[parcel_id]

    geo = geocoder.geocode(raw_address) if evidence.startswith("parcel") else None
    if geo is None:
        # Text agreed and there is nothing independent to check it against.
        return Match(parcel_id, Tier.PROBABLE, evidence + "|no_geometry_check",
                     text_score, None, 1)

    pt = Point(geo.x, geo.y)
    distance = float(parcel.geometry.centroid.distance(pt))

    if distance > MAX_CENTROID_DISTANCE_M:
        log.warning("%s: text matched %s but the geocode is %.0f m away",
                    raw_address, parcel_id, distance)
        return Match(parcel_id, Tier.AMBIGUOUS, evidence + "|distance_implausible",
                     text_score, distance, 1)

    inside = bool(parcel.geometry.contains(pt))
    confirming = geo.kind in CONFIRMING_KINDS

    if inside and confirming:
        return Match(parcel_id, Tier.CONFIRMED, evidence + f"|inside|{geo.kind}",
                     text_score, distance, 1)

    return Match(parcel_id, Tier.PROBABLE,
                 evidence + f"|{'inside' if inside else 'near'}|{geo.kind}",
                 text_score, distance, 1)

The evidence string is doing real work: it is a compact, greppable record of how each match was reached, which is what makes a bad batch diagnosable weeks later. A run whose matches are 90% county_address_point|inside|address_point is healthy; a run whose matches are suddenly 60% geocode_interpolated|near|interpolated has lost access to the address-point layer, and the total match rate will not have moved at all.

Edge cases and gotchas jump to heading

Condominium and townhouse plats. One street address, 140 units, and possibly 140 parcel identifiers — or one parcel with 140 ownership interests, depending on how the county models it. Text matching returns many candidates and there is no correct single answer, so the honest output is AMBIGUOUS with the candidate list preserved. The consuming question decides the rule: a zoning check applies to the whole plat and can use any member, while a permit count must not multiply by 140.

Rural addresses and mailing addresses. A parcel’s mailing address is frequently the owner’s home, not the land. Matching on it puts a permit on a suburb lot when the parcel is farmland. If the parcel layer has a distinct situs_address field, use it and only it; where only a mailing address exists, the match cannot be better than PROBABLE no matter how well the text agrees.

Address ranges. 100-110 Main St describes several lots. Expanding the range produces candidates that may span parcels with different zoning, which makes it an AMBIGUOUS case rather than a match — and one worth surfacing, because a rezone ordinance written against a range is a real thing that needs to be applied to several parcels.

Renumbering. Municipalities renumber streets, and the old numbers persist in permit history for years. A match against a current parcel layer using a historical address silently fails, or worse, matches the new occupant of that number. This is a temporal join, and it needs the effective-date discipline described in temporal versioning & snapshots: the address as of the permit date, against the parcel layer as of the permit date.

Points on a shared boundary. A geocode landing exactly on a lot line satisfies contains for neither parcel, or for both, depending on the predicate. This is the same edge-contact question that spatial overlay analysis deals with, and the resolution is the same: choose the predicate deliberately, and treat a boundary hit as ambiguity rather than resolving it by floating-point accident.

Integration points jump to heading

The matcher produces (parcel_id, tier, evidence) triples, and two downstream areas consume them differently.

Compliance work consumes only CONFIRMED matches. A setback check or a permitted-use determination made against a PROBABLE match is a compliance answer about a parcel nobody established, and it will be indistinguishable from a correct one in the output. The tier therefore has to travel with the record rather than being dropped after the join, in the same way the resolver tier travels with a mapped code in zoning taxonomy mapping.

Analytical work can consume PROBABLE as well, provided the tier is reported alongside the total. “412 permits in this district” and “412 permits, of which 47 are probable matches” are different claims, and the second one is the one that survives review.

Both feed back into scheduling: unmatched addresses accumulate, and their rate is a leading indicator that a portal has changed how it publishes addresses. Tracking the four tiers as a time series — rather than a single match percentage — is what turns this join from a one-off script into something operable.

Compliance and audit artifacts jump to heading

Three records make a match defensible.

The match record itself: input address, normalised key, parcel identifier, tier, evidence string, text score, and distance. This is the row an auditor reads to understand why a permit was attributed to a parcel, and the evidence string is the part that makes it intelligible without rerunning anything.

The geocoder response archive, keyed by normalised address. Geocoders are external services whose results change without notice; without an archived response, a match made last March cannot be reproduced or defended, because rerunning the geocoder today may return a different point. Archiving the raw response — including the result kind — makes the match reproducible in the sense that data lineage & provenance tracking requires.

The ambiguity queue, with its resolutions. When a human resolves a condominium plat or an address range, that decision is reusable and should be recorded as an override with an author and a date, not applied as a one-off data edit. Overrides that are recorded accumulate into exactly the local knowledge that made the county’s own address-point layer better than any geocoder in the first place.

Measuring the join rather than trusting it jump to heading

A matching pipeline needs a standing measurement, because its quality degrades from the outside: a portal changes its address format, a geocoder alters its interpolation behaviour, a county re-numbers a street. None of those events produce an error, and all of them move the tier mix.

Tier mix across four weeks, and what a shift in it means Stacked bar chart of match tiers across four weekly runs of about 8 000 permit addresses. In the first three weeks confirmed matches dominate at roughly 6 900 a week, with around 800 probable, 200 ambiguous and 100 unmatched. In week four confirmed matches collapse to 3 100 while probable rises to 4 400, even though the overall match rate barely moves — the signature of the county address-point layer becoming unavailable and the geocoder fallback taking over. Tier mix across four weeks, and what a shift in it means 0 2000 4000 6000 8000 10000 812 6886 week 1 790 6907 week 2 835 6864 week 3 4400 3100 week 4 Permit addresses matched unmatched ambiguous probable confirmed The overall match rate moves by under 1% between week 3 and week 4.
Week four is the case a single match-rate metric cannot see: the overall rate is essentially unchanged while evidence has been replaced by inference. Watching the confirmed share, rather than the total, is what turns this from a surprise into an alert.

The measurement that works is a small reconciliation sample rather than an aggregate rate. Take fifty matches at random each week, stratified across the four tiers, and check them against the county’s own parcel viewer by hand. It takes under an hour and it is the only method that detects a systematic error, because a systematic error moves the aggregate rate by a few points at most while being wrong in the same direction every time. A match rate of 94% is compatible with both a healthy pipeline and one that is quietly attributing every corner lot to its neighbour.

Alongside the sample, three counters are worth alerting on. The share of matches whose evidence mentions a geocoder rather than an address point, because a jump means the authoritative layer has become unavailable. The share of AMBIGUOUS results, because a jump means either a plat was recorded or the normalisation stopped collapsing unit designators. And the mean centroid distance for confirmed matches, because it drifts upward when a geocoder starts interpolating more often — long before the match rate moves at all.

FAQ jump to heading

Should I geocode first and match spatially, or match text first?

Text first, using the county’s own address-point layer if one exists, because it encodes the assessor’s assignment of address to parcel and no general geocoder has that knowledge. Geocoding first inverts the authority: it makes an external service’s interpolation the primary evidence and the local record a check on it. Use the geocoder to generate candidates when text fails, and to confirm text matches when its result kind is precise.

Is a high match rate a good sign?

Not on its own. A pipeline that resolves ambiguity by taking the nearest candidate reaches a very high match rate and is wrong on precisely the parcels where the answer mattered. Report the four tiers separately and treat a rising CONFIRMED share as the goal; a rising overall rate with a falling CONFIRMED share means guesses are replacing evidence.

How should condominium plats be handled?

Decide by the consuming question rather than by picking a unit. For a zoning determination the whole plat shares one designation, so any member parcel answers correctly and the result can be reported against the plat. For counting permits or units, multiplying by the unit count is a serious error, so the match must stay ambiguous and the consumer has to aggregate deliberately.

Do I need to store the geocoder's raw response?

Yes, if any match will ever be defended. Geocoders are external services that change without notice, so a match made in March cannot be reproduced by re-running the geocoder in September — it may legitimately return a different point. Archiving the response, including its result kind, is what makes the match reproducible rather than merely recorded.