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.
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.
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.
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.
Related
- Extracting Apartment and Suite Numbers With Regex — the production pattern and its component-by-component breakdown.
- Normalizing Secondary Unit Designators to USPS Standards — the mapping table and the rules for ordering multiple designators.
- Handling Missing Unit Numbers in Delivery Validation — turning a missing-unit result into a targeted customer prompt.
- Regex Patterns for US Address Parsing — the primary-line parser this splitter runs in front of.
- USPS CASS Certification Guidelines — how certified validation reports unit-level results.