Extracting Apartment and Suite Numbers With Regex

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.

Three inputs and what the anchor does to each Three rows. STE 400 at the end of a line matches the designator branch and yields STE 400. A line ending in hash 12 matches the hash branch and yields UNIT 12. A line reading 100 UNIT AVE contains a designator word but does not end with a unit, so the end anchor causes a clean non-match. input result 1600 N PENN AVE STE 400 word=STE ident=400 designator branch 742 EVERGREEN TER #12 hash=12 hash branch, designator inferred 100 UNIT AVE None end anchor rejects it — correct

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.

A small pre-pass keeps the main pattern simple A two-stage flow. A pre-normalisation pass rewrites ordinal floor phrases such as 3RD FLOOR into FL 3, and spelled-out forms such as SECOND FLOOR into FL 2. The rewritten line then goes through the main unit pattern unchanged. A note explains that folding these variants into the main alternation would double its size. … MAIN ST 3RD FLOOR … MAIN ST SECOND FL as received ordinal pre-pass two substitutions, no capture … MAIN ST FL 3 … MAIN ST FL 2 now matches the main pattern Folding ordinals into the main alternation roughly doubles its branch count for a case that is a two-line rewrite, and every extra branch is tried on every record that does not match. Keep rewrites and extraction separate. The same argument applies to punctuation folding and to non-breaking space removal.

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.

Parse order: split, unit, street, city Four sequential stages. The address is first split into delivery and city lines. The unit extractor then claims the end of the delivery line. The street parser receives what remains. The city-line parser handles postcode and locality separately. Each stage removes an ambiguity the next would otherwise face. 1 · split lines delivery vs city 2 · unit claims the line end 3 · street number, name, suffix 4 · city line locality, state, ZIP Run stage 3 before stage 2 and the street-name group swallows the unit; run stage 2 on an unsplit address and the end anchor never matches because the postcode is in the way. Each arrow is an ambiguity removed, which is why the sequence is worth enforcing in code rather than by convention.

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.