Normalizing Secondary Unit Designators to USPS Standards

TL;DR: Standardisation is a dictionary lookup plus three rules — uppercase the identifier without retyping it, keep multiple designators in written order, and allow the handful of designators that legitimately carry no identifier. The table and the code are below; the extraction step that feeds them is covered in parsing unit and secondary designators.

The Mapping Table

Publication 28 defines a fixed vocabulary of secondary designators and their approved abbreviations. The mapping is many-to-one — several spoken and written forms collapse onto one abbreviation — and it is stable, which is what makes a plain dictionary the right implementation rather than anything cleverer.

Approved Accepts Identifier required
APT Apartment, Apt, Apt., Ap yes
STE Suite, Ste, Ste., Su yes
UNIT Unit, U, bare # yes
BLDG Building, Bldg, Bld yes
FL Floor, Flr, Fl yes
RM Room, Rm yes
DEPT Department, Dept yes
SPC Space, Spc yes
TRLR Trailer, Trlr yes
LOT Lot yes
SLIP Slip yes
PIER Pier yes
HNGR Hangar, Hngr yes
BSMT Basement, Bsmt no
LBBY Lobby, Lbby no
FRNT Front, Frnt no
REAR Rear no
PH Penthouse, PH optional
UPPR Upper, Uppr no
LOWR Lower, Lowr no

The right-hand column is the part teams usually miss, and it drives a real validation rule. BSMT and LBBY identify a delivery point on their own; a parser that requires an identifier after every designator will reject them as malformed and route perfectly valid addresses into a review queue.

Three Rules That Do the Work

Standardisation looks like a lookup and is actually a lookup plus three decisions that each destroy data if made carelessly. All three come down to the same principle: change the representation, never the content.

Three rules and the data each one protects Three panels. Uppercasing without retyping preserves leading zeros and alphanumeric identifiers that an integer cast would destroy. Preserving designator order keeps building-then-suite sequences meaningful. Allowing identifier-free designators keeps basement and lobby addresses valid rather than routing them to review. uppercase, never retype "04" stays "04" "4b" → "4B" an int cast turns 04 into 4 and drops 4B entirely store as text, always keep written order BLDG C STE 210 not STE 210 BLDG C — the sequence narrows from site to delivery point a list, not a pair allow bare designators BSMT · LBBY · REAR these are complete delivery points with no number to give do not demand a number Each rule protects information the customer supplied. Standardisation changes how a value is written; it must never change what the value is.

The leading-zero case in the first panel is worth dwelling on because it survives casual testing. Buildings that number units 01 through 48 are common, and a pipeline that passes identifiers through an integer column silently rewrites 04 as 4. Both look plausible; only one matches the building’s own signage and the carrier’s records.

The Normalizer

from __future__ import annotations

from dataclasses import dataclass

DESIGNATOR_MAP: dict[str, str] = {
    "APARTMENT": "APT", "APT": "APT", "AP": "APT",
    "SUITE": "STE", "STE": "STE", "SU": "STE",
    "UNIT": "UNIT", "U": "UNIT",
    "BUILDING": "BLDG", "BLDG": "BLDG", "BLD": "BLDG",
    "FLOOR": "FL", "FLR": "FL", "FL": "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", "HNGR": "HNGR",
    "BASEMENT": "BSMT", "BSMT": "BSMT",
    "LOBBY": "LBBY", "LBBY": "LBBY",
    "FRONT": "FRNT", "FRNT": "FRNT",
    "REAR": "REAR",
    "PENTHOUSE": "PH", "PH": "PH",
    "UPPER": "UPPR", "UPPR": "UPPR",
    "LOWER": "LOWR", "LOWR": "LOWR",
}

# Designators that identify a delivery point without an identifier.
STANDALONE: frozenset[str] = frozenset(
    {"BSMT", "LBBY", "FRNT", "REAR", "UPPR", "LOWR", "PH"}
)


@dataclass(frozen=True)
class Designator:
    abbreviation: str
    identifier: str | None

    def render(self) -> str:
        return self.abbreviation if self.identifier is None else (
            f"{self.abbreviation} {self.identifier}"
        )


class UnknownDesignator(ValueError):
    """Raised for a designator token absent from the mapping."""


def normalise(raw_word: str, raw_ident: str | None) -> Designator:
    """Standardise one designator/identifier pair to Publication 28 form."""
    key = raw_word.strip().upper().rstrip(".")
    try:
        abbreviation = DESIGNATOR_MAP[key]
    except KeyError as exc:
        raise UnknownDesignator(raw_word) from exc

    ident = (raw_ident or "").strip().upper().strip("#:-") or None
    if ident is None and abbreviation not in STANDALONE:
        raise ValueError(f"{abbreviation} requires an identifier")
    return Designator(abbreviation, ident)

Raising on an unknown designator rather than passing it through is the right default for a batch pipeline. An unrecognised token is either a new variant worth adding to the map or a parsing error worth investigating, and both are things you want to hear about — a silent pass-through turns them into a slow accumulation of non-standard values nobody notices.

Handling Multiple Designators

Campus and industrial addresses routinely carry two designators, and their order is meaningful: it narrows from the largest container to the delivery point. Model the secondary portion as an ordered list rather than a single pair, and the case stops being special.

def normalise_all(pairs: list[tuple[str, str | None]]) -> list[Designator]:
    """Standardise a sequence of designator pairs, preserving written order."""
    return [normalise(word, ident) for word, ident in pairs]


def render_secondary(designators: list[Designator]) -> str:
    """Render the standardised secondary line for display or a label."""
    return " ".join(d.render() for d in designators)


assert render_secondary(
    normalise_all([("Building", "C"), ("Suite", "210")])
) == "BLDG C STE 210"

Storing the list rather than a flattened string is what keeps later questions answerable. A tenant search by building, a report grouped by floor, and a delivery label all read the same structure without re-parsing, and adding a third designator changes no schema.

One ordered list, three consumers A stored ordered list of two designators, building C and suite 210, feeds three consumers. A label renderer joins them into a printed line. A report groups on the building element alone. A delivery-point key concatenates all elements. None of the three re-parses the original string. stored structure [BLDG C, STE 210] printed label BLDG C STE 210 building-level report groups on the first element only delivery-point key concatenates every element in order

Auditing an Existing Column

Most teams adopt this mapping on data that already exists, and the first useful exercise is to find out what is actually in the column today. A frequency count of distinct designator tokens usually produces a short head and a long, revealing tail: three or four dominant forms, then a scattering of typos, partner-specific conventions and values that are not designators at all.

Run that count before editing the map. Tokens appearing in more than a handful of records are worth an explicit mapping entry; tokens appearing once or twice are usually corrupt input better served by the review queue than by an ever-growing dictionary. The distinction matters because every entry added to the map is a decision you are committing to maintain, and a map that has absorbed every one-off typo becomes impossible to reason about.

The audit also surfaces the reverse problem: designators that are correct, common, and absent from your map because nobody in the original market used them. Marina and airfield addresses bring SLIP, PIER and HNGR; agricultural and mobile-home parks bring LOT and TRLR. None of these are exotic, and all of them are silently rejected by a map built from a purely urban sample.

Keep the audit query in the repository next to the map itself and run it after each significant new data source is onboarded. A partner whose export uses a designator vocabulary you have never seen shows up immediately as a spike in unknown tokens, which is far easier to act on than a slow rise in review-queue volume with no obvious cause.

Edge Cases and Failure Modes

A designator that is also a street type. PIER and LOT appear in street names as well as in secondary designators. Because standardisation runs after extraction, and extraction is anchored to the end of the delivery line, the ambiguity is already resolved before this code sees the token — which is the main reason for keeping the two steps separate.

Non-ASCII identifiers. European and Latin American units carry letters outside ASCII (2ºD, 1ºesq). Uppercasing is Unicode-aware in Python, so .upper() handles them correctly, but the resulting value should not be forced through an ASCII fold — that turns º into nothing and loses the distinction between 2D and 2ºD. Apply the character normalisation rules for the match key only, and keep the display value intact.

Penthouse without a number. PH appears both as a bare designator and as PH2. The STANDALONE set marks it optional, which is the only entry in the table needing that treatment and the reason the set exists rather than a boolean column.

A designator arriving already standardised. Idempotence matters: normalise("STE", "400") must return the same value as normalise("Suite", "400"). The map contains every approved abbreviation as a key onto itself for exactly this reason, so re-running the normaliser over already-clean data is a no-op rather than an error.

The Audit Query, Drawn

A frequency count of designator tokens produces a characteristic shape, and recognising it is what turns the audit into a decision rather than a list.

Designator token frequency, head and tail A descending bar chart of designator token frequencies. Three tokens dominate, a middle band of about eight tokens is common enough to deserve explicit mappings, and a long tail of rare tokens is marked as review-queue material rather than dictionary entries. APT · STE · UNIT — map these first common enough to deserve a mapping long tail — review queue, not dictionary entries Every dictionary entry is a maintenance commitment; the tail is where that commitment stops paying for itself.

Integration Note

The output of this step feeds two places with different needs. The rendered string goes onto labels and into the address shown back to a customer; the structured list goes into the delivery-point key and into any building-level grouping. Keeping both, rather than regenerating one from the other on demand, is what lets CASS validation submit a correctly formatted secondary line while your own analytics still group by building.