Geocoding Quality & Address Standardization
Geocoding is the step where a surveillance dataset stops being a list of text strings and starts being a map, and it is the step where the largest and least visible errors enter. This guide is part of Spatial Epidemiology Fundamentals & Data Standards, and it covers how to normalize addresses before they reach a matcher, how to account for match type rather than match rate, how to detect the differential non-match that silently biases every downstream statistic, and what an auditable geocoding record has to contain.
Concept & Epidemiological Alignment
A geocoder answers a question that has no single correct answer: given a text string that a human wrote, where on the earth is it? Every implementation resolves that question through a cascade of increasingly approximate strategies, and the strategy that succeeded is far more important than the fact that something succeeded. A rooftop match places a case at a parcel; a street-interpolated match places it at a position estimated along a block face; a ZIP centroid match places it at the population-weighted middle of an area that may be forty kilometres across. All three are reported as “geocoded” by default, and only the first is safe to analyse at tract scale.
Three assumptions have to hold before geocoded case data is epidemiologically usable:
- Match type is retained per record, not summarized. A file with a 94% match rate and no match-type column cannot be validated, because the 94% may be nine parts rooftop and one part ZIP centroid, or the reverse, and those are different datasets.
- Non-match is not random. Addresses fail to match for reasons that correlate with rurality, housing type, recency of construction, and the administrative quality of the source system. Dropping non-matches therefore drops a non-random subset of cases, which is a selection bias, not a data-cleaning step.
- The reference layer has a vintage. A geocoder is only as current as the address range file behind it. A 2026 case in a subdivision built in 2024 cannot match against a 2022 reference, no matter how well the address is written.
The figure states the central discipline of the topic. There is no correct global decision about coarse matches, because the right answer depends on the geographic scale of the analysis about to be run. A county-level rate can absorb a ZIP centroid; a tract-level Getis-Ord Gi* hotspot detection run cannot. Keeping the match type on every record is what lets the same geocoded file serve both analyses honestly.
Match-Type Decision Table
| Match type | Typical positional error | Smallest defensible unit | Include in point-pattern work? |
|---|---|---|---|
| Parcel / rooftop | 5–15 m | Block group | Yes |
| Address-range interpolated | 30–80 m | Census tract | Yes, above ~200 m bands |
| Street-segment midpoint | 100–400 m | Census tract, with caution | No |
| ZIP / postal centroid | 1–8 km | County | No |
| Place or city centroid | 5–40 km | State | No |
| Unmatched | — | None | No — report, do not drop |
The right-hand column matters most for K-Function & Point Pattern Analysis, where a coarse match does not merely add noise. Centroid matches stack many cases at exactly the same coordinate, and a second-order estimator reads coincident points as extreme clustering at short distances — a manufactured signal that is indistinguishable from a real one without the match-type column.
Spatial Data Prerequisites
- A normalized address string, produced before matching, and retained. The matcher’s own parsing is a black box; a separately stored normalized form is what makes a non-match diagnosable.
- A reference layer whose vintage is recorded and whose coverage is known for the study extent. For US work this is usually a Census TIGER address-range file, a state or county parcel layer, or a commercial composite.
- A declared coordinate reference on output. Geocoders return WGS84 unless told otherwise; assert it rather than assume it, and reproject before any distance work per Coordinate Reference Systems for Public Health.
- A stable record identifier that survives the round trip, so results can be joined back without matching on the address string a second time.
- A defined handling rule for non-residential addresses — correctional facilities, long-term care, shelters, hospitals — which are legitimate matches that must not be treated as residences.
Production Implementation
The pipeline below separates normalization from matching, records the match type, and refuses to silently drop anything.
# Normalize, geocode, and account for every record by match type.
# Pinned: python 3.11, pandas==2.2.2, geopandas==1.0.1, usaddress==0.5.10,
# pyproj==3.6.1, requests==2.32.3
import hashlib
import json
import logging
import re
import pandas as pd
import geopandas as gpd
import usaddress
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("geocode.pipeline")
# Match types ordered from most to least precise. The ordinal is what downstream
# analyses filter on, so it is part of the published schema, not a debug field.
MATCH_RANK = {
"parcel": 0, "rooftop": 0, "range_interpolated": 1,
"street_center": 2, "zip_centroid": 3, "place_centroid": 4, "unmatched": 9,
}
DIRECTIONALS = {"N": "NORTH", "S": "SOUTH", "E": "EAST", "W": "WEST",
"NE": "NORTHEAST", "NW": "NORTHWEST", "SE": "SOUTHEAST", "SW": "SOUTHWEST"}
def normalize(raw: str) -> dict:
"""Parse and canonicalize one address. Returns the parts plus a normalized string.
Normalization is deliberately separate from matching: when a record fails to
match, the stored normalized form is what tells you whether the address was
malformed or the reference layer simply lacks it."""
s = re.sub(r"\s+", " ", (raw or "").upper()).strip()
s = s.replace(".", "").replace(",", " ")
try:
parts, _ = usaddress.tag(s)
except usaddress.RepeatedLabelError:
return {"ok": False, "reason": "unparseable", "normalized": s}
number = parts.get("AddressNumber")
street = parts.get("StreetName")
if not number or not street:
# No house number or no street name -> the matcher can only fall back to a
# centroid, so reject here and route to manual review rather than accept a
# ZIP-centroid "match" that will be indistinguishable later.
return {"ok": False, "reason": "missing_number_or_street", "normalized": s}
pre = DIRECTIONALS.get(parts.get("StreetNamePreDirectional", ""), parts.get("StreetNamePreDirectional", ""))
post = DIRECTIONALS.get(parts.get("StreetNamePostDirectional", ""), parts.get("StreetNamePostDirectional", ""))
stype = parts.get("StreetNamePostType", "")
unit = parts.get("OccupancyIdentifier", "")
normalized = " ".join(x for x in [number, pre, street, stype, post] if x)
return {
"ok": True, "normalized": normalized, "unit": unit,
"city": parts.get("PlaceName", ""), "state": parts.get("StateName", ""),
"zip": (parts.get("ZipCode", "") or "")[:5],
}
def geocode_frame(df: pd.DataFrame, addr_col: str, id_col: str, geocode_batch) -> gpd.GeoDataFrame:
"""Normalize, submit to `geocode_batch`, and return every input row with a
match_type. `geocode_batch` is any callable taking a list of normalized
addresses and returning dicts with lon, lat and match_type."""
df = df.sort_values(id_col).reset_index(drop=True) # stable order => reproducible run
parsed = df[addr_col].map(normalize)
df["normalized_address"] = parsed.map(lambda p: p["normalized"])
df["parse_ok"] = parsed.map(lambda p: p["ok"])
df["reject_reason"] = parsed.map(lambda p: p.get("reason"))
sendable = df.loc[df["parse_ok"]]
log.info("normalized: %d parsed, %d rejected pre-match", len(sendable), len(df) - len(sendable))
results = geocode_batch(sendable["normalized_address"].tolist())
res = pd.DataFrame(results, index=sendable.index)
df = df.join(res[["lon", "lat", "match_type"]])
df["match_type"] = df["match_type"].fillna("unmatched")
df["match_rank"] = df["match_type"].map(MATCH_RANK).fillna(9).astype(int)
counts = df["match_type"].value_counts().to_dict()
log.info("match-type counts: %s", json.dumps(counts, sort_keys=True))
log.info("matched at tract-safe precision: %.1f%%",
100.0 * (df["match_rank"] <= 1).mean())
gdf = gpd.GeoDataFrame(
df, geometry=gpd.points_from_xy(df["lon"], df["lat"]), crs="EPSG:4326")
# Unmatched rows keep a null geometry on purpose: they stay in the file so the
# denominator is reconstructable, and any spatial op must exclude them explicitly.
gdf.loc[gdf["match_rank"] == 9, "geometry"] = None
return gdf
def run_signature(config: dict) -> str:
"""Stable hash of everything that could move a coordinate."""
return hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
Three details in that code carry most of the value. Rejecting a record before the matcher when it has no house number prevents the single most common quality failure, which is a partial address silently resolving to a postal centroid and entering the analysis as a match. Retaining unmatched rows with null geometry keeps the file’s row count reconcilable against its source, which is the control that catches silent loss. And match_rank as an integer, rather than a free-text label, is what makes a downstream filter expressible as match_rank <= 1 and auditable as a number.
The normalization step is worth spelling out field by field, because every one of its decisions is reversible only if it was recorded. What reaches the matcher is a canonical string assembled from parsed components, and what stays behind in the record is the evidence a reviewer needs when the match later turns out to be wrong:
Two of those decisions are worth defending explicitly. Expanding the directional rather than abbreviating it is arbitrary in itself — what matters is that the same convention is applied to the case file and to any reference data joined against it, so a single constant governs both. Withholding the unit identifier is not arbitrary: a unit number rarely moves the coordinate and frequently breaks the match, and it is precisely the field that turns a building-level location into a household-level one, which is a disclosure concern rather than a matching one.
Parameter Selection & Tuning
- The precision cut-off is per analysis, not per file. Set it from the geographic unit:
match_rank <= 1for tract or block-group work,<= 2for county work, and no cut-off for a state total. Record the cut-off used with each output. - Batch size trades throughput against retry cost. Batches of 1,000–5,000 records are typical; on failure the whole batch is retried, so very large batches turn a transient error into an expensive one. The retry discipline is the same one described in Batch Routing Error Handling.
- Tie-break policy when a matcher returns several candidates: prefer the highest-precision type first, then the candidate inside the expected jurisdiction, then fail. Never take the first candidate silently.
- Re-geocoding cadence. Reference layers gain addresses continuously, so unmatched records are worth resubmitting on a schedule — quarterly is common — and any record that changes match type on a later run must be flagged rather than quietly overwritten.
Edge Cases & Failure Modes
Apartment and unit numbers. A unit identifier rarely changes the coordinate but frequently breaks the match when passed through. Strip it for matching, keep it in the record, and never use it to disambiguate a household in a released file — it is an identifier in the sense that matters for privacy-preserving spatial analytics.
Highway and rural-route addresses. These often carry no street name a matcher recognises and fall through to centroids, which is why the rural end of a study area loses precision first. Handle them explicitly rather than letting them fail.
Institutional addresses. A nursing home with two hundred residents produces two hundred cases at one coordinate. That is a true coincidence, not a geocoding error, and it will still break a point-pattern estimator. Flag institutional matches with a separate column so they can be excluded from second-order analysis and retained for rate work.
Cross-border addresses. A case whose address resolves outside the reporting jurisdiction is either a data-entry error, a recent mover, or a genuine out-of-area resident treated locally. All three need a rule, and the rule must be applied before rates are computed, since the denominator does not include them.
Silent geocoder upgrades. A vendor improving their reference layer will change coordinates for records that previously matched. Without the reference vintage in the run signature, a year-over-year trend can move for reasons that have nothing to do with disease.
The remedy is not a better geocoder — the spread persists across vendors — but an explicit accounting. Compute the match rate within each stratum you care about, report it beside the results, and where the spread is large, weight or model the non-match rather than ignoring it. A rate map built from a file whose rural stratum matched at 61% is understating rural incidence by construction, and no amount of spatial smoothing repairs that.
Compliance & Audit Controls
- Log the reference vintage and the geocoder version in the run signature, alongside the configuration hash. Two runs of identical code against different reference layers are different analyses.
- Publish the match-type distribution with every derived product. A rate map’s metadata should carry the counts behind the figure in the cascade above, not merely a percentage.
- Never transmit addresses to a third-party service without an executed agreement. A full street address plus a diagnosis is protected health information in transit; the choice between an in-house and a hosted matcher is a compliance decision governed by Compliance Mapping Frameworks, not a performance one.
- Retain the normalized string, not the raw one, in the analytic store, and keep the raw address in the restricted source system. The normalized form is what a reviewer needs to diagnose a non-match; the raw form is the identifier.
- Reconcile row counts at every stage. Input rows must equal matched plus unmatched plus pre-match rejects, and that identity belongs in the run log as a number, not an assertion.
Production Implementation Checklist
FAQ
Is a 95% match rate good? It is not interpretable on its own. A 95% rate that is 90% rooftop is excellent; a 95% rate that is 60% rooftop and 35% ZIP centroid is a county-scale dataset being presented as a tract-scale one. Ask for the match-type distribution, not the rate.
Should unmatched records be dropped? Not from the file. They can be excluded from a specific analysis, with the exclusion counted and reported, but removing them from the dataset destroys the only evidence that the analysed sample is incomplete.
Can I improve the match rate by loosening the matcher’s tolerance? Loosening tolerance converts non-matches into low-precision or incorrect matches. That improves the headline number and degrades the data. Prefer better normalization, a more current reference layer, and manual review of a sampled subset.
Does geocoding count as de-identification? No — the opposite. A rooftop coordinate is at least as identifying as the address it came from. De-identification happens afterwards, using the methods in Privacy-Preserving Spatial Analytics.
Related Topics
- Spatial Epidemiology Fundamentals & Data Standards — the parent section, covering the full preparation pipeline this step sits in.
- Coordinate Reference Systems for Public Health — what to do with the coordinates a matcher returns.
- Precision Standards in Epi-Mapping — how many decimal places a match type entitles you to store.
- Areal Interpolation & Boundary Harmonization — what to do when the geography a case lands in changes between vintages.
- K-Function & Point Pattern Analysis — the method most damaged by coarse matches stacking at centroids.