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.
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.
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.
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.
Related jump to heading
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Attribute Normalization Rules — the same graded-confidence pattern, applied to codes rather than addresses
- CRS Alignment Strategies — a geocode is only useful against geometry whose projection is proven
- Spatial Overlay Analysis — the predicate choices that decide what “inside a parcel” means
- Temporal Versioning & Snapshots — matching a historical address needs the parcel layer as of that date