Parsing Unit and Secondary Address Designators

As part of the Core Address Parsing & Standardization pipeline, the secondary designator is the component most often dropped and most expensive to lose: it is the difference between a parcel reaching apartment 12 and being returned to sender. This page covers detecting the secondary portion of a delivery line, classifying the designator, standardising it to the approved abbreviation, and handling the several ways real data omits or mangles it.

Secondary designators are also the component that most reliably breaks regex patterns for US address parsing written without them in mind. Because the unit sits between the street suffix and the city line, an unbounded street-name group will swallow it, and the resulting record geocodes to the right building with the wrong — or no — delivery point.

Prerequisites

The Four Shapes a Unit Arrives In

Real input carries units in four structurally different ways, and a parser that handles only the first will silently discard the rest. The distinction matters because each shape needs a different detection rule, and two of them are ambiguous with parts of the primary address.

Four ways a secondary designator appears in input A four-row reference. A designator word with an identifier is unambiguous and matched on the designator vocabulary. A hash-prefixed identifier is unambiguous and matched on the hash symbol. A bare trailing identifier is ambiguous with the civic number and requires the primary address to be complete first. A separate address line is unambiguous but requires the line to be identified before parsing. Shape Detection rule Ambiguity risk STE 400 designator vocabulary none #12 hash prefix none … MAIN ST 4B trailing token after a complete street high — civic number line 2: APT 9 line identification, then vocabulary moderate — which line is delivery

Row three is where most silent errors come from. 100 MAIN ST 4B is unambiguous to a human and genuinely ambiguous to a pattern applied left to right, which is why the rule must be ordered: extract the civic number and suffix first, and only then consider whether a trailing token remains. Anything else risks parsing 4B as the house number of a street named 100 MAIN.

Step 1 — Split Secondary From Primary

The safest structure runs the split before any component parsing. Search for a designator token or hash prefix; everything from that point to the end of the line is the secondary portion, and everything before it is the primary. This ordering means the primary parser never sees the unit at all and cannot mis-assign it.

from __future__ import annotations

import re

# Publication 28 Appendix C2 designators, plus the spelled-out and punctuated
# variants that occur in real input. Longest alternatives first so that
# "APARTMENT" is preferred over "APT" when both could match.
DESIGNATORS: dict[str, str] = {
    "APARTMENT": "APT", "APT": "APT", "APT.": "APT",
    "SUITE": "STE", "STE": "STE", "STE.": "STE", "SU": "STE",
    "UNIT": "UNIT",
    "BUILDING": "BLDG", "BLDG": "BLDG", "BLD": "BLDG",
    "FLOOR": "FL", "FL": "FL", "FLR": "FL",
    "ROOM": "RM", "RM": "RM",
    "DEPARTMENT": "DEPT", "DEPT": "DEPT",
    "SPACE": "SPC", "SPC": "SPC",
    "TRAILER": "TRLR", "TRLR": "TRLR",
    "LOT": "LOT", "SLIP": "SLIP", "PIER": "PIER", "HANGAR": "HNGR",
}

_DESIGNATOR_ALT = "|".join(
    sorted((re.escape(k) for k in DESIGNATORS), key=len, reverse=True)
)

SECONDARY_RE = re.compile(
    rf"""
    (?:^|[\s,])                       # boundary before the designator
    (?:
        (?P<designator>{_DESIGNATOR_ALT})\.?\s*(?P<ident>[A-Z0-9][A-Z0-9\-/]*)
      | \#\s*(?P<hash_ident>[A-Z0-9][A-Z0-9\-/]*)   # bare hash form
    )
    \s*$                              # secondary runs to end of line
    """,
    re.IGNORECASE | re.VERBOSE,
)

Compiling at module level matters here: this pattern is applied to every record in a batch, and rebuilding the alternation per call dominates the cost of the parse itself. The sorted(..., key=len, reverse=True) step is not cosmetic — regex alternation is first-match, so without it APT would match the first three characters of APARTMENT and leave a stray MENT in the identifier.

Step 2 — Standardise the Designator and Identifier

Once split, standardisation is a lookup plus a case fold. The identifier is uppercased and stripped of surrounding punctuation but otherwise preserved: unit identifiers are frequently alphanumeric (4B, 12-A, PH2), and any attempt to coerce them to integers loses rows.

from dataclasses import dataclass


@dataclass(frozen=True)
class SplitAddress:
    """A delivery line split into primary and secondary portions."""

    primary: str
    designator: str | None      # standard abbreviation, e.g. "APT"
    identifier: str | None      # e.g. "4B"
    designator_inferred: bool   # True when no designator word was present


def split_secondary(line: str) -> SplitAddress:
    """Split a single delivery line into primary and secondary components."""
    text = " ".join(line.split())          # squeeze internal whitespace
    match = SECONDARY_RE.search(text)
    if match is None:
        return SplitAddress(text, None, None, False)

    primary = text[: match.start()].rstrip(" ,")
    if match.group("hash_ident"):
        return SplitAddress(primary, "UNIT", match.group("hash_ident").upper(), True)

    raw = match.group("designator").upper().rstrip(".")
    return SplitAddress(
        primary=primary,
        designator=DESIGNATORS[raw],
        identifier=match.group("ident").upper(),
        designator_inferred=False,
    )

The designator_inferred flag deserves emphasis. When input carries #12, the designator is a guess — the building may call it a unit, an apartment or a suite — and recording that the value was inferred rather than stated lets a downstream validator treat it more loosely, and lets a label renderer choose a neutral rendering.

Step 3 — Decide What the Unit Means for Your Keys

The unit’s place in the canonical key is a modelling decision with real consequences in both directions, and it is the question most likely to be settled by accident rather than deliberately. The diagram below shows the two failure modes.

What each key choice costs Two panels. Excluding the unit merges every apartment in a building onto one key, which is correct for site analytics and wrong for parcel delivery. Including the unit gives each apartment its own key and its own geocoding call, which is correct for delivery and wasteful for building-level work. The recommendation is to emit both keys. unit excluded from the key 100 n main st ← 48 apartments one geocoding call for the building one cache entry, one coordinate right for: site analytics, coverage wrong for: parcel routing unit included in the key 100 n main st apt 4b 48 keys for one building 48 cache entries, 48 calls right for: parcel routing, validation wrong for: building-level metrics Emit both. One extra column removes the compromise, and the geocoding call can still be made once per building — most providers return the same coordinate for every unit in it anyway.

The note in the last line is the practical resolution of the cost objection. Even when the unit is part of the record key, the geocoding lookup can be keyed on the building alone, because almost no provider returns a distinct coordinate per apartment. That gives you per-unit records and per-building call volume, which is the combination the canonical key builder is designed to emit.

Designator Reference

Full word Standard abbreviation Requires an identifier Common raw variants
Apartment APT yes Apt., APARTMENT, Ap
Suite STE yes Suite, Ste., SU
Unit UNIT yes Unit, U, #
Building BLDG yes Bldg., Building, BLD
Floor FL yes Flr, Floor, 2nd Floor
Room RM yes Rm., Room
Department DEPT yes Dept., Department
Space SPC yes Spc, Space
Basement BSMT no Bsmt, Basement
Lobby LBBY no Lobby
Penthouse PH optional PH, Penthouse, PH2

The “requires an identifier” column drives a validation rule that catches a surprising number of malformed rows: a record whose secondary portion is APT with nothing after it is incomplete, not a unit. Designators such as BSMT and LBBY are the exceptions — they identify a delivery point on their own, and a parser that demands an identifier will reject them.

Edge Cases

Floor written as an ordinal. 2ND FLOOR and 3RD FL appear constantly in commercial data. Extend the identifier group to accept an ordinal suffix and normalise 2ND to 2, keeping the ordinal form only in the display string.

Two designators in one line. BLDG C STE 210 is legal and common on campuses. Capture the sequence rather than a single pair — the standardised form keeps both, in the order written, because a building without its suite is not a delivery point.

Range units. STE 200-210 describes one tenancy spanning several suites. Keep it verbatim in the identifier; splitting it produces two records for one customer, and normalising it to the first value silently loses the range.

The designator that is part of the street name. 100 UNIT AVE contains the token UNIT and no secondary designator. Anchoring the secondary match to the end of the line handles this correctly, which is the main argument for the end-anchored pattern above over a free-floating search.

Non-US conventions. Austrian Stiege 3/Tür 14, Spanish 2ºD and UK Flat 4 follow different grammars entirely. Route by country before applying the US vocabulary — the mechanics are covered under parsing European address conventions.

Performance and Vectorisation

For a pandas pipeline, apply the split with Series.str.extract against the same compiled pattern rather than a row-wise apply. On a million-row frame the extract form runs in roughly two seconds against about forty for apply, because the pattern is executed in compiled code once per column rather than once per row through the Python interpreter.

import pandas as pd


def split_secondary_frame(df: pd.DataFrame, col: str = "address_line") -> pd.DataFrame:
    """Vectorised secondary-designator extraction for a DataFrame."""
    cleaned = df[col].str.strip().str.replace(r"\s+", " ", regex=True)
    parts = cleaned.str.extract(SECONDARY_RE)

    designator = (
        parts["designator"].str.upper().str.rstrip(".").map(DESIGNATORS)
        .where(parts["designator"].notna(), other=None)
    )
    identifier = parts["ident"].fillna(parts["hash_ident"]).str.upper()

    out = df.copy()
    out["unit_designator"] = designator.fillna(
        pd.Series("UNIT", index=df.index).where(parts["hash_ident"].notna())
    )
    out["unit_identifier"] = identifier
    out["unit_inferred"] = parts["hash_ident"].notna()
    return out

Keep the cleaned column rather than recomputing it: the whitespace squeeze is needed by the primary parser too, and doing it twice on a large frame is measurable. The same argument applies to case folding — normalise once at ingestion, then let every later stage assume the invariant.

One further scaling note: the designator alternation grows with every variant you add, and alternation cost is linear in the number of branches tried before a match. If your corpus is dominated by three or four designators — most consumer files are almost entirely APT, STE and UNIT — order the dictionary so those appear first among same-length entries. The saving is small per record and visible across a nightly batch of several million, and it costs nothing beyond reordering a literal.

Troubleshooting

Units disappear from a subset of records. Almost always a line-splitting problem upstream: the secondary designator was on its own line and the loader kept only the first. Check the raw payload before blaming the pattern.

Four checks when units go missing A four-step diagnostic ladder. First check whether the raw payload contains the unit at all. Second check whether the correct line was selected as the delivery line. Third check whether the pattern matched but the value was overwritten. Fourth check whether the downstream schema has a column for it. Each step names the symptom that points to it. 1 · is it in the raw payload? symptom: whole partners missing units — the source form never collected one 2 · was the right line selected? symptom: only multi-line addresses affected — line 2 was dropped at load 3 · did the pattern match and get overwritten? symptom: parse logs show a unit, the stored row does not — a later stage rebuilt the line 4 · does the destination schema even have the column?

Identifiers arrive as floats. 4.0 instead of 4 means the column passed through a numeric type somewhere — usually a spreadsheet export or a read_csv without an explicit dtype. Fix it at the boundary; casting back to string after the damage cannot recover 04 or 4B.

Validation rejects units the building genuinely has. New buildings appear in postal reference data before their unit lists do. Treat a rejected unit on a confirmed building as unconfirmed rather than invalid, and retry after the next reference-data refresh instead of asking the customer to correct something that is already correct.

FAQ

Should the unit designator be part of the deduplication key?

It depends on the consumer. Parcel delivery needs the unit in the key, because APT 2 and APT 3 are different destinations. Building-level analytics needs it out, because both are the same site. Emit two keys — one with the unit and one without — and let each consumer join on the one it needs.

Why does a missing apartment number fail delivery when the street address is correct?

A multi-unit building has one street address and many delivery points. Without the secondary designator the carrier cannot resolve which delivery point is meant, so the item is returned or held. Validation services report this distinctly from an invalid address, which is what lets a checkout form prompt for the unit alone.

How should a bare number after the street be interpreted?

A trailing token such as 100 MAIN ST 4B is almost always a unit, but only if the street portion already has its own civic number and suffix. Require both before treating the trailing token as a unit, and record that the designator was inferred rather than stated so the assumption is visible downstream.

Do secondary designators belong on their own line?

USPS prefers the secondary designator appended to the primary line when it fits, and on a separate line above the primary line otherwise. Store it as its own field regardless, and decide line layout at render time — that keeps one schema serving both label printing and matching.

What should happen to a unit that a validation service rejects?

Keep the primary address, drop the unit into a review field, and mark the record as building-confirmed but unit-unconfirmed. Discarding the typed unit loses information the customer supplied, and keeping it in the delivery field asserts a correctness the validator refused to grant.