Validating Address Normalization Before Geocoding
When a geocoding batch comes back with an 84% match rate, there are two possible explanations and no way to tell them apart: the reference data does not contain those addresses, or the strings sent were not addresses in a form anything could match. Normalisation separates the two. It parses each input into components, standardises the parts that have controlled forms, applies structural rules that catch nonsense before it costs an API call, and gates the records that cannot be parsed at all. What comes out the other side is a match rate that means something — the prerequisite for every measurement described in Geocoding and Address Data Validation.
Prerequisites
- Python 3.10+ with a locale-appropriate parser:
usaddressfor US-style addresses,pyapfor several English-speaking locales, orpostal(libpostal bindings) for international data. Libpostal is much heavier to install and much better at messy multi-country input. - A controlled list of street types and directionals for your locale. The examples use a short illustrative map; a production system uses the official postal authority list.
- A postal-code format rule per country you handle. These are stable, well documented and cheap to enforce.
- A decision about what is not an address. Care-of lines, delivery instructions, building nicknames and “as above” placeholders are common in operational data and must be stripped or rejected explicitly rather than sent to a geocoder.
Step-by-Step Procedure
Step 1 — Clean the raw string without losing it
# normalise/step1_clean.py
import re
NOISE_PATTERNS = [
r"\bc/o\b.*", r"\bcare of\b.*", r"\battn?:?\b.*",
r"\bas above\b", r"\bsame as .*", r"\bn/?a\b",
r"\bleave (with|at)\b.*", r"\bring bell\b.*",
]
NOISE_RE = re.compile("|".join(NOISE_PATTERNS), re.I)
def clean(raw: str) -> dict:
"""Strip non-address noise and normalise whitespace, keeping the original."""
if raw is None:
return {"raw": None, "cleaned": None, "rejected": "null input"}
text = raw.replace("\n", ", ").replace("\t", " ")
removed = bool(NOISE_RE.search(text))
text = NOISE_RE.sub(" ", text)
text = re.sub(r"[;|]+", ",", text)
text = re.sub(r"\s*,\s*", ", ", text)
text = re.sub(r"\s+", " ", text).strip(" ,")
if len(text) < 5 or not re.search(r"[A-Za-z]", text):
return {"raw": raw, "cleaned": text, "rejected": "too short to be an address"}
return {"raw": raw, "cleaned": text, "noise_removed": removed, "rejected": None}
Verification: run over a thousand raw records and inspect everything with rejected set. The rejects should be genuinely unusable — empty strings, “unknown”, pure phone numbers. Anything real appearing there means the cleaning rules are too aggressive.
Step 2 — Parse into components
# normalise/step2_parse.py
import usaddress
REQUIRED = {"AddressNumber", "StreetName"}
def parse(cleaned: str) -> dict:
try:
tagged, kind = usaddress.tag(cleaned)
except usaddress.RepeatedLabelError as exc:
return {"parsed": False, "reason": f"ambiguous components: {exc.original_string[:60]}"}
components = dict(tagged)
missing = REQUIRED - components.keys()
return {
"parsed": True,
"kind": kind, # 'Street Address', 'PO Box', 'Intersection'
"components": components,
"missing_required": sorted(missing),
"complete": not missing,
}
Verification: group the outputs by kind. A batch that is supposed to be street addresses but contains 8% PO boxes has a data-collection problem: a PO box has no building to geocode, and forcing one through produces a locality centroid every time.
Step 3 — Expand abbreviations against a controlled list
# normalise/step3_expand.py
STREET_TYPES = {
"st": "Street", "str": "Street", "rd": "Road", "ave": "Avenue", "av": "Avenue",
"dr": "Drive", "cl": "Close", "ln": "Lane", "ct": "Court", "cres": "Crescent",
"blvd": "Boulevard", "pl": "Place", "sq": "Square", "ter": "Terrace",
}
DIRECTIONALS = {"n": "North", "s": "South", "e": "East", "w": "West",
"ne": "Northeast", "nw": "Northwest", "se": "Southeast", "sw": "Southwest"}
def expand(components: dict) -> dict:
out = dict(components)
for field, table in (("StreetNamePostType", STREET_TYPES),
("StreetNamePreType", STREET_TYPES),
("StreetNamePreDirectional", DIRECTIONALS),
("StreetNamePostDirectional", DIRECTIONALS)):
value = out.get(field)
if value:
out[field] = table.get(value.lower().strip("."), value.title())
if "StreetName" in out:
out["StreetName"] = out["StreetName"].title()
return out
def canonical_line(components: dict) -> str:
order = ("AddressNumber", "StreetNamePreDirectional", "StreetNamePreType", "StreetName",
"StreetNamePostType", "StreetNamePostDirectional", "OccupancyType",
"OccupancyIdentifier", "PlaceName", "StateName", "ZipCode")
return " ".join(components[k] for k in order if components.get(k))
Verification: count distinct canonical street names before and after expansion. A well-formed expansion collapses “MAIN ST”, “Main Street” and “main st.” into one value; if the count barely moves, the abbreviation table does not match the conventions in your data.
Step 4 — Apply structural rules
# normalise/step4_rules.py
import re
POSTCODE_RE = {
"GB": re.compile(r"^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$", re.I),
"US": re.compile(r"^\d{5}(-\d{4})?$"),
"CA": re.compile(r"^[A-Z]\d[A-Z]\s*\d[A-Z]\d$", re.I),
}
def structural_findings(components: dict, country: str = "US") -> list[str]:
findings = []
number = components.get("AddressNumber", "")
if number and not re.match(r"^\d+[A-Za-z]?(-\d+[A-Za-z]?)?$", number):
findings.append(f"implausible house number {number!r}")
if number.isdigit() and int(number) > 99999:
findings.append(f"house number {number} out of plausible range")
pc = (components.get("ZipCode") or "").strip()
rule = POSTCODE_RE.get(country)
if pc and rule and not rule.match(pc):
findings.append(f"postal code {pc!r} does not match the {country} format")
if not pc:
findings.append("no postal code — match will rely on locality alone")
street = components.get("StreetName", "")
if street and len(street) < 2:
findings.append(f"street name {street!r} is implausibly short")
return findings
Verification: the postal-code rule is the highest-value check here because it is objective and cheap. Records failing it are frequently transposed digits or a locality typed into the postcode field, both of which are fixable at source and neither of which any geocoder will resolve correctly.
Step 5 — Gate on parse quality
# normalise/step5_gate.py
def gate(record: dict) -> dict:
"""Decide whether a record is fit to send to the geocoder."""
if record.get("rejected"):
return {"send": False, "bucket": "unusable", "reason": record["rejected"]}
if not record.get("parsed"):
return {"send": False, "bucket": "unparsed", "reason": record.get("reason")}
if record.get("kind") == "PO Box":
return {"send": False, "bucket": "po_box",
"reason": "no physical premises to geocode"}
if not record.get("complete"):
return {"send": True, "bucket": "partial",
"reason": f"missing {record['missing_required']}",
"expect": "locality or postcode level at best"}
if record.get("structural"):
return {"send": True, "bucket": "suspect", "reason": "; ".join(record["structural"])}
return {"send": True, "bucket": "clean", "reason": None}
Verification: report the bucket counts before every geocoding run. Those five numbers turn “the match rate dropped” into a diagnosable statement — a fall in the clean bucket is an input-quality regression, while a stable clean bucket with a falling match rate points at the reference data.
Interpreting Results
| Bucket | What it means | What to do |
|---|---|---|
clean |
Fully parsed, all required components, no structural findings | Geocode; this is the population your match rate should be quoted over |
suspect |
Parsed but a rule fired (bad postcode, odd number) | Geocode and compare; a suspect record that matches at rooftop is fine, one that falls back is evidence |
partial |
Missing street or number | Geocode with reduced expectations; never count these as rooftop failures |
po_box |
No physical premises | Do not geocode; report separately |
unparsed |
Parser could not tag the string | Route to review; these are usually a handful of recurring patterns worth adding rules for |
unusable |
Not an address | Fix at source; the record has no spatial content |
The single most useful output of this stage is the pairing of bucket with eventual match level. Once you can say “94% of clean records match at rooftop, 41% of suspect records fall back to postcode”, the normalisation rules pay for themselves — the buckets predict outcomes, so a batch can be assessed before it is geocoded at all.
Gotchas & Edge Cases
Title-casing breaks some real names. “McDonald Street” becomes “Mcdonald Street” under naive title case, and Irish, Dutch and Scottish name particles suffer similarly. Keep an exception list, or preserve the original casing and normalise only for comparison rather than for storage.
The same abbreviation means different things in different locales. “Cl” is Close in the UK and rarely used in the US; “Ter” is Terrace in both but also appears as part of genuine street names. Locale-specific tables are not optional if you handle more than one country.
Parsers are probabilistic and confident. usaddress tags every token, including ones it is unsure about, and RepeatedLabelError catches only outright contradictions. Sample the parser output regularly against human judgement; a silently mis-tagged locality is worse than a parse failure because it passes the gate.
Unit numbers migrate into the house number field. “12/4 Mill Road” is flat 4 at number 12 in some conventions and number 12–4 in others. Decide which convention your data uses and encode it, because the two produce different geocoding results and neither errors.
Normalisation must be versioned. Change the abbreviation table and the canonical strings change, which changes the cache keys and the deduplication results. Store the normalisation version alongside the record, exactly as the geocoder version is stored, so a difference between two runs can be attributed correctly.
When to Escalate
- A recurring unparsed pattern above a few percent is a source-system problem, not a parser problem. Take it to the team that captures the data; adding parser rules for a broken input form is a permanent maintenance cost.
- Postal-code format failures concentrated in one source point at a field mapping error upstream — often a locality or a phone extension landing in the postcode column. Fix the mapping rather than the strings.
- A parse quality drop with no code change means the input distribution moved: a new supplier, a new intake form, or a system migration. This belongs in the drift monitoring described in Attribute Schema Mapping for Spatial Datasets.
- PO boxes or non-premises records forming a material share of a dataset intended for spatial analysis is a scoping conversation, not a cleaning task — those records will never have a building position.
Frequently Asked Questions
Does normalisation actually improve match rates?
Substantially, and more importantly it makes the match rate interpretable. Modern geocoders tolerate messy input, so normalisation buys a few points of match rate at best — but it converts an unknown mixture of geocoder limitations and input defects into two separately measurable numbers. Once parse failures are gated out, a drop in match rate is a statement about the reference data rather than about your input pipeline.
Should the normalised or the raw string be sent to the geocoder?
Send structured components where the provider accepts them, and the normalised single line otherwise. Structured input removes the geocoder's own parsing step, which is the largest source of variation between providers. Always keep the raw string: it is the only thing that lets you reproduce a result after the normalisation rules change.
How should unit and sub-premises information be handled?
Parse it out and keep it in its own field, but do not expect it to affect the coordinate. Most reference data positions the building rather than the flat, so units mainly matter for deduplication and for explaining legitimate coordinate coincidence. Sending unit information to a geocoder that cannot use it occasionally degrades a match rather than improving it.
Is a postcode enough on its own?
Only for postcode-level analysis, and only if you record that it is what you did. A postcode-only query returns a postcode centroid by construction, which is legitimate for regional aggregation and unusable at building scale. The important thing is to mark those records so they are not later mistaken for failed rooftop matches.
Related
- Geocoding and Address Data Validation — where the parse buckets feed the fitness gate
- Detecting Centroid Fallback in Geocoded Results — the outcome that unparsed input reliably produces
- Mapping Attribute Constraints to GeoJSON Schemas — expressing the address component contract as an enforceable schema