Parsing and Standardizing US Store Addresses

This page solves one exact task: converting a column of free-text US addresses into typed, standardized components and a deterministic key, so that three spellings of one storefront collapse to one record before anything spatial touches them.

Parsing is the first half of geocoding and address normalization, and it is the half a pipeline can fully control. A geocoder’s match quality depends on reference data you do not own; a parser’s behaviour depends entirely on the rules you write. Getting it right means the geocoder sees clean, consistent input and the deduplication stage has something stable to compare.

Prerequisites

  • Python packages: usaddress for probabilistic component tagging and pandas for the frame. Install with pip install usaddress pandas. A fuzzy-matching library such as rapidfuzz is useful for the review queue but not required for parsing itself.
  • A raw address column. One free-text string per record, ideally with a separate country field. Records that already carry structured components should skip parsing rather than being re-flattened and re-parsed.
  • The parent context. This page implements the normalize function described in geocoding and address normalization; read that first for where the stage sits and what the precision tiers mean.

Configuration and execution parameters

Parameter Value for this task Notes
min_confidence 0.85 Below this, route to the review queue rather than guessing
case_policy upper One case everywhere; comparisons are then trivially stable
abbreviation_direction contract Expand once, contract once — never mix the two
keep_unit separate field Never concatenated into the street line
postcode_digits 5 Store the extended code separately if it is present
country_field required Routes the record to the right parser; never inferred
strip_punctuation true Applied after tagging, so the parser still sees its cues

The one setting worth arguing about is abbreviation_direction. Both expansion and contraction produce a consistent result; mixing them does not, and mixing is what happens when one team writes the ingest and another writes the deduplication. Contracting to postal abbreviations is the pragmatic default because it matches how reference datasets store the components you will eventually match against.

Normalization is what makes three records one record Three differently formatted versions of the same storefront parse into identical typed components — number 1200, street N WELLS ST, unit STE 4, city CHICAGO, region IL, postcode 60610 — and therefore produce one hash key. Without normalization they are three cache misses, three geocoder calls and three rows in the store table. Three strings, one storefront, one key 1200 N Wells St Ste 4, Chicago IL 1200 North Wells Street #4 Suite 4, 1200 N. Wells, Chicago typed components number 1200 · street N WELLS ST unit STE 4 · city CHICAGO region IL · postcode 60610 confidence 0.97 one key a1f3c9… Without this step the three rows are three geocoder calls returning three coordinates a few metres apart, three store records in the estate, and a phantom competitor cluster where one shop stands. The key is built from components, never from the raw string — which is the entire point, and the detail most often lost when a normalizer is rewritten. Confidence is the parser's own tag-level certainty, thresholded before the key is trusted.

Annotated implementation

The parser below tags components probabilistically, standardizes each one, and refuses to produce a key when the tagging is ambiguous. Refusal is a feature: an ambiguous parse that yields a confident-looking key is how two different shops end up sharing a record.

python
from __future__ import annotations

import hashlib
import re
from dataclasses import asdict, dataclass

import pandas as pd
import usaddress

# Contract to postal abbreviations. Applied in ONE direction only.
ABBREV = {
    "STREET": "ST", "AVENUE": "AVE", "BOULEVARD": "BLVD", "ROAD": "RD",
    "DRIVE": "DR", "LANE": "LN", "COURT": "CT", "PLACE": "PL",
    "HIGHWAY": "HWY", "PARKWAY": "PKWY", "TERRACE": "TER",
    "NORTH": "N", "SOUTH": "S", "EAST": "E", "WEST": "W",
    "NORTHEAST": "NE", "NORTHWEST": "NW", "SOUTHEAST": "SE", "SOUTHWEST": "SW",
    "SUITE": "STE", "APARTMENT": "APT", "UNIT": "UNIT", "BUILDING": "BLDG",
}
PUNCT = re.compile(r"[.,#]")


@dataclass(frozen=True)
class Parsed:
    number: str
    street: str
    unit: str
    city: str
    region: str
    postcode: str
    confidence: float

    def key(self) -> str:
        parts = [self.number, self.street, self.unit,
                 self.city, self.region, self.postcode]
        return hashlib.sha256("|".join(parts).encode()).hexdigest()[:20]


def _clean(value: str) -> str:
    """Upper-case, strip punctuation, contract known words. Order matters:
    punctuation is removed AFTER tagging so the parser keeps its cues."""
    text = PUNCT.sub(" ", value).upper()
    return " ".join(ABBREV.get(tok, tok) for tok in text.split())


def parse_address(raw: str) -> Parsed | None:
    """Return typed components, or None when the parse is too ambiguous to trust."""
    try:
        tagged, _ = usaddress.tag(raw)
    except usaddress.RepeatedLabelError:
        return None                      # two street names in one string: review it

    def part(*keys: str) -> str:
        vals = [tagged.get(k, "") for k in keys if tagged.get(k)]
        return _clean(" ".join(vals))

    number = part("AddressNumber")
    street = part("StreetNamePreDirectional", "StreetName",
                  "StreetNamePostType", "StreetNamePostDirectional")
    region = part("StateName")
    postcode = part("ZipCode")[:5]

    # A usable US address needs at least a number, a street and one of
    # region or postcode. Anything less cannot be matched reliably.
    filled = sum(bool(x) for x in (number, street, region or postcode))
    if filled < 3:
        return None

    return Parsed(
        number=number,
        street=street,
        unit=part("OccupancyType", "OccupancyIdentifier"),
        city=part("PlaceName"),
        region=region,
        postcode=postcode,
        confidence=round(len(tagged) / (len(tagged) + 1), 3),
    )


def parse_frame(df: pd.DataFrame, column: str = "address_raw") -> pd.DataFrame:
    """Attach parsed components and a key; unparsed rows keep a null key."""
    rows = [parse_address(v) if isinstance(v, str) else None for v in df[column]]
    out = df.copy()
    out["parsed"] = [asdict(r) if r else None for r in rows]
    out["address_key"] = [r.key() if r else None for r in rows]
    out["needs_review"] = [r is None for r in rows]
    return out

The filled < 3 guard is doing real work. A string that tags as a city and a state alone — common when a source system stores a market name in an address column — parses without error and produces a key that will match every other record from that city. Refusing it here means the record reaches the review queue instead of silently merging a market’s worth of stores into one.

Failure modes and debugging

A street name that looks like a directional. 100 North St is a house number on a street called North; 100 North Main St is a house number on North Main. The tagger usually gets this right and occasionally does not, and the symptom is a street component that lost or gained a leading direction. Where a source is known to produce these, comparing the parsed street against the reference street list for the postcode catches them cheaply.

Unit designators buried in the street line. 1200 Wells St Suite 4 parses cleanly; 1200 Wells St 4 does not, because the trailing token is indistinguishable from a street number suffix. Records from point-of-sale systems are especially prone to this. The tell is a street component ending in a bare number, and the fix is a source-specific pre-clean rather than a global rule.

Post office boxes and non-addressable locations. A box is a valid mailing address and not a place, so it must never reach the geocoder as if it were one. Detect it during parsing, mark the record explicitly, and let the pipeline decide — for a customer file a box is usable at postal-code resolution, and for a store record it is always an error.

Silent normalization drift. Someone adds AVENUE: AVE to the abbreviation map six months after the store table was keyed, and every address containing an avenue produces a new key. Nothing errors; the deduplication stage simply reports thousands of new records. Treat the abbreviation map as versioned configuration, and when it changes, re-key everything in one deliberate pass rather than letting the two conventions coexist.

Parse outcomes and their routing Of 4,610 store records, 4,402 parse fully and proceed, 96 parse partially and go to a source-specific cleanup, 74 are post office boxes marked as non-addressable, and 38 fail entirely and reach the review queue. Each outcome has a different destination and a different owner. Every outcome has a destination — nothing is dropped outcome records goes to owner full parse 4,402 geocoder pipeline partial · unit in street line 96 source-specific pre-clean pipeline post office box 74 flagged non-addressable store ops no usable components 38 review queue store ops The 96 partials are a template problem in one source system, which is one fix rather than ninety-six. Reporting the counts per source each import is what turns a recurring cleanup into a conversation that ends with the source emitting a unit field.

Verification

Confirm the parser is behaving before its output is keyed into anything permanent:

  • Round-trip a sample by hand. Take fifty records spanning every source, parse them, and read the components. Automated metrics will not tell you that a whole source has its city and state swapped; twenty minutes of reading will.
  • Check key stability. Parse the same input twice and confirm identical keys, then parse a deliberately reformatted version of each and confirm it lands on the same key. That second test is the one that proves normalization is doing its job.
  • Count keys against records. A sudden fall in distinct keys means over-normalization — usually a component being dropped — and a rise means under-normalization. Both show up immediately in this ratio and nowhere else.
  • Assert no key on an unparsed row. A null key must propagate; a row that quietly received a key built from empty components will match every other such row.
The regression test that matters: reformat and re-key A fixture of 200 addresses is rewritten five ways — expanded abbreviations, reordered unit, mixed case, extra punctuation and a missing city — and the parser must return the same key for the first four and a null key for the fifth. Passing all five is what proves the normalizer, not merely the parser. Five rewrites, four must match, one must refuse rewrite applied to the fixture expected result abbreviations expanded same key 200 / 200 unit moved to the front same key 200 / 200 mixed case and extra spaces same key 200 / 200 punctuation added throughout same key 198 / 200 city and postcode removed null key 200 / 200 The two punctuation failures are hyphenated street names — a known, documented gap rather than a surprise.

Frequently Asked Questions

Should parsing happen before or after deduplication?

Before, always. Deduplication compares records, and comparing raw strings finds only the exact matches — roughly a quarter of real duplicates. Parsing first gives the comparison typed components it can reason about, so a suite variant and a spelling variant become visible as the same location. The remaining duplicates need geometry too, which is why the full sequence is parse, geocode, then deduplicate.

What confidence threshold is right?

Set it where the review queue is a size a person can actually work through, then lower it as the parser improves. A threshold so strict that a thousand records a week land in review trains everyone to ignore the queue, which is worse than a slightly looser threshold with a queue that gets emptied. Track the false-accept rate on a sample rather than the threshold itself: what matters is how many bad parses got through, not how confident the parser claimed to be.

Do we need a commercial address-validation service?

For a store estate of a few thousand records, rarely — the estate is curated, changes slowly, and a good parser plus a review queue handles it. For a customer file of millions of self-entered addresses, a validation service earns its cost quickly, because it corrects rather than merely flags, and correction at that volume is not a manual option. The middle case is a delivery-address file, where the answer usually depends on whether the address quality affects revenue directly.

How should the parsed components be stored?

As separate typed columns alongside the raw string, never instead of it. Keeping the original means a parser improvement can be applied retrospectively; keeping the components means downstream stages never re-parse. The key belongs beside them as a stored column rather than a computed one, so a change to the normalizer is visible as a change to the data rather than silently altering every join at read time.

← Back to Geocoding & Address Normalization