TL;DR: Anchor the unit match to the end of the delivery line, try the designator vocabulary before the bare-hash form, and never let the pattern run before the civic number and street suffix have been claimed. The full pattern and a runnable parser are below; the surrounding workflow lives in parsing unit and secondary designators.
The Production Pattern
from __future__ import annotations
import re
UNIT_WORDS: dict[str, str] = {
"APARTMENT": "APT", "APT": "APT",
"SUITE": "STE", "STE": "STE", "SU": "STE",
"UNIT": "UNIT",
"BUILDING": "BLDG", "BLDG": "BLDG",
"FLOOR": "FL", "FLR": "FL", "FL": "FL",
"ROOM": "RM", "RM": "RM",
"SPACE": "SPC", "SPC": "SPC",
"DEPARTMENT": "DEPT", "DEPT": "DEPT",
}
_ALT = "|".join(sorted((re.escape(w) for w in UNIT_WORDS), key=len, reverse=True))
UNIT_RE: re.Pattern[str] = re.compile(
rf"""
(?:^|[\s,]) # boundary
(?:
(?P<word>{_ALT})\.?[\s:#-]*(?P<ident>[A-Z0-9][A-Z0-9\-/]{{0,9}})
| \#[\s]*(?P<hash>[A-Z0-9][A-Z0-9\-/]{{0,9}})
)
\s*$ # runs to end of line
""",
re.IGNORECASE | re.VERBOSE,
)
The bounded repetition {0,9} on the identifier is deliberate. Unit identifiers are short;
allowing an unbounded run lets the group swallow an entire city name when the input is
malformed, which turns a clean non-match into a confidently wrong extraction.
Pattern Breakdown
| Segment | What it matches | Why it is necessary |
|---|---|---|
(?:^|[\s,]) |
Start of line, a space or a comma | Prevents matching UNIT inside 100 UNIT AVE, where the token is part of the street name |
(?P<word>{_ALT}) |
Any designator, longest alternative first | First-match alternation would otherwise let APT match the head of APARTMENT |
\.? |
An optional trailing period | Handles Apt. and Ste. without a separate branch |
[\s:#-]* |
Separators between designator and identifier | Covers STE 400, STE-400, STE#400 and STE: 400 |
(?P<ident>[A-Z0-9][A-Z0-9\-/]{0,9}) |
A short alphanumeric identifier | Accepts 4B, 12-A, 2/3; the bound stops runaway captures |
|\ \#\s*(?P<hash>…) |
The bare hash form | #12 is extremely common and carries no designator word |
\s*$ |
End of line | The anchor that makes the whole pattern safe to run on a full delivery line |
The end anchor does most of the safety work. Without it, 100 FLOOR STREET APT 3 matches
FLOOR first and produces a unit of STREET, which is both wrong and plausible enough to
survive review.
Minimal Runnable Implementation
from dataclasses import dataclass
@dataclass(frozen=True)
class UnitMatch:
designator: str
identifier: str
inferred: bool # True when no designator word was present
primary: str # the line with the unit removed
def extract_unit(line: str) -> UnitMatch | None:
"""Extract a secondary unit from one delivery line, or None if absent."""
text = " ".join(line.split())
m = UNIT_RE.search(text)
if m is None:
return None
primary = text[: m.start()].rstrip(" ,")
if m.group("hash") is not None:
return UnitMatch("UNIT", m.group("hash").upper(), True, primary)
word = m.group("word").upper().rstrip(".")
return UnitMatch(UNIT_WORDS[word], m.group("ident").upper(), False, primary)
if __name__ == "__main__":
for sample in (
"1600 N Pennsylvania Ave, Suite 400",
"742 Evergreen Terrace #12",
"100 Unit Ave",
"88 Harbour Rd Bldg C Ste 210",
):
print(sample, "->", extract_unit(sample))
The fourth sample returns only STE 210, leaving BLDG C in the primary string. That is
the correct behaviour for a single-pass extractor and the reason the calling workflow
applies the pattern repeatedly until it stops matching when campus-style addresses are
expected.
Vectorised Usage
import pandas as pd
def add_unit_columns(df: pd.DataFrame, col: str = "address_line") -> pd.DataFrame:
"""Extract unit designator and identifier for a whole frame."""
cleaned = df[col].fillna("").str.strip().str.replace(r"\s+", " ", regex=True)
parts = cleaned.str.extract(UNIT_RE)
out = df.copy()
out["unit_identifier"] = parts["ident"].fillna(parts["hash"]).str.upper()
out["unit_designator"] = (
parts["word"].str.upper().str.rstrip(".").map(UNIT_WORDS)
.fillna(pd.Series("UNIT", index=df.index).where(parts["hash"].notna()))
)
out["unit_inferred"] = parts["hash"].notna()
return out
str.extract runs the compiled pattern once per column in compiled code. On a million rows
it completes in roughly two seconds where a row-wise apply of extract_unit takes tens
of seconds — the same pattern, the same results, and an order of magnitude less time in the
interpreter.
Edge Cases and Failure Modes
A range of suites. STE 200-210 is one tenancy. The identifier class accepts the
hyphen, so the range survives as written. Splitting it would create two customer records
for one lease.
A designator with no identifier. … MAIN ST APT matches nothing, because the
identifier group requires at least one character. That is intentional: an empty unit is
incomplete data and belongs in a review queue, not in the delivery field as an empty
string.
Ordinal floors. 3RD FLOOR fails the pattern, because FLOOR is followed by nothing.
Pre-normalise ordinals ahead of the extractor — rewriting 3RD FLOOR to FL 3 in a small
pre-pass is simpler than teaching the main pattern to read backwards.
Trailing ZIP on the same line. If the input is a single string containing the city and postcode, the end anchor prevents the pattern from matching at all. Split the delivery line from the city line before extraction; a unit is a property of the delivery line only.
Testing the Pattern Properly
A unit extractor is one of the few components where a table-driven test suite genuinely pays for itself, because the failure modes are enumerable and each one is a single line of input. Build the suite from four groups and keep them separate, so a failure names its own category rather than sending you to read the pattern.
The first group is straightforward positives: one row per designator in the vocabulary,
each with a plain numeric identifier. These catch a broken alternation immediately — if
someone adds a designator to the dictionary and forgets that _ALT is built at import
time, every row in this group still passes, and that is exactly the assurance the group is
there to give.
The second group is punctuation and separator variants of a single designator: Ste 400,
Ste. 400, STE-400, STE#400, Suite: 400. All five must yield the same standardised
pair. This group is where most real regressions land, because separators are the part of
the pattern people edit when adding support for a new upstream partner.
The third group is negatives that must not match: a street name containing a designator word, a line ending in a postcode, an empty designator with no identifier, and a line whose only content is a recipient name. A pattern that has drifted usually starts failing here first, and a negative that silently becomes a positive is the failure most likely to reach production, because nothing downstream complains about an extra field.
The fourth group is the awkward set — ranges, campus addresses with two designators, and ordinal floors — where the expected result is a documented decision rather than an obvious truth. Writing those expectations down in the test file is what stops the decision being re-litigated every time someone new reads the pattern, and it makes an intentional change visible as a test edit rather than as a silent behavioural shift.
Run the suite against the compiled pattern object rather than through the public function
where you can. A test that exercises UNIT_RE directly fails with the group values in the
assertion message, which is considerably more useful during debugging than a dataclass
comparison that reports only that two objects differ.
Where the Pattern Sits in the Sequence
Order is the property that makes this pattern safe, and it is worth drawing once because every failure mode discussed above comes from getting it wrong.
Integration Note
This extractor runs first in the parsing sequence, before
street numbers and suffixes are parsed,
and its primary output is what that parser receives. Running it second inverts the
dependency and reintroduces exactly the ambiguity the end anchor was added to remove. The
standardisation of the captured designator — the mapping from Suite to STE — is covered
in
normalizing secondary unit designators to USPS standards.
Related
- Parsing Unit and Secondary Designators — the parent workflow, including where the unit belongs in your keys.
- Normalizing Secondary Unit Designators to USPS Standards — the abbreviation table and multi-designator ordering.
- How to Parse Street Numbers and Suffixes With Regex — the primary-line parser that consumes this one’s output.