Extracting ZIP+4 Codes With Regex in Python

TL;DR: Anchor the ZIP to the end of the address line, accept an optional four-digit add-on separated by a hyphen or a space, and keep the whole thing as text so 02134 does not become 2134. The parent workflow is regex patterns for US address parsing.

The Pattern

from __future__ import annotations

import re

ZIP_RE: re.Pattern[str] = re.compile(
    r"""
    (?<![\d-])                 # not preceded by a digit or hyphen
    (?P<zip5>\d{5})            # the five-digit base code
    (?:
        [\s-]                  # hyphen or space before the add-on
        (?P<plus4>\d{4})       # the four-digit delivery-point add-on
    )?
    \s*$                       # closes the address line
    """,
    re.VERBOSE,
)


def extract_zip(line: str) -> tuple[str, str | None] | None:
    """Return (zip5, plus4) from an address line, or None when absent."""
    m = ZIP_RE.search(" ".join(line.split()))
    if m is None:
        return None
    return m.group("zip5"), m.group("plus4")

The lookbehind is what keeps 12345-6789 from matching as a bare five-digit code starting at the wrong offset, and it also prevents a nine-digit run from being read as a ZIP followed by an unrelated number. Together with the end anchor it makes the pattern safe to run against a whole address line.

Pattern Breakdown

Segment Matches Why it is necessary
(?<![\d-]) a position not preceded by a digit or hyphen stops a match starting mid-number, e.g. inside a phone number
(?P<zip5>\d{5}) exactly five digits the base ZIP code; never fewer, never more
[\s-] one hyphen or space both separators occur; USPS prints a hyphen, forms often use a space
(?P<plus4>\d{4}) exactly four digits the delivery-point add-on, optional by design
\s*$ end of the line the anchor that makes the whole pattern unambiguous

The optional group is the important structural choice. A pattern that requires the add-on silently drops every record that has only a five-digit code, which is the large majority of customer-entered data, and a pattern that treats the add-on as part of one nine-digit group cannot tell you which part was supplied.

Leading Zeros Are Not Optional

What an integer column does to a ZIP code Two columns compare the same four ZIP codes stored as text and as integers. Codes beginning with zero, from Puerto Rico, Massachusetts, New Hampshire and Maine, lose their leading digit when stored as integers, becoming four-digit values that match nothing. stored as text — correct 00901 San Juan, PR 02134 Boston, MA 03301 Concord, NH 04101 Portland, ME stored as integer — corrupted 901 matches nothing 2134 matches nothing 3301 matches nothing 4101 matches nothing Roughly one in fifty US ZIP codes begins with a zero. An integer column loses every one of them, silently.

This is the most common data-loss bug in US address handling, and it rarely originates in the parser. It comes from a spreadsheet export, a read_csv without dtype=str, or a database column typed as integer years ago — and by the time it is noticed, the original values are gone.

What the Add-On Actually Means

The four-digit add-on identifies a delivery segment within the five-digit area: one side of a street block, a floor of an office building, a bank of PO boxes, or a single high-volume recipient. That specificity is what makes it valuable and what makes it dangerous to guess.

An add-on supplied by a customer is a claim. It may be correct, out of date, or copied from a neighbouring address, and no regex can tell the difference — the pattern confirms only that four digits were present. Treat a parsed add-on as input to CASS validation rather than as a verified value, and store the validator’s returned add-on separately from the one that arrived.

The distinction matters commercially as well as technically. Postal discounts depend on the add-on being correct according to current USPS data, and a mailing prepared with customer-supplied add-ons that were never validated will not qualify — regardless of how many of them happen to be right.

Separating a ZIP From a Street Number

Three lines that could match wrongly, and do not Three input lines. An address ending in a ZIP+4 matches both groups. An address whose street number is five digits but which ends in a city and state name produces no match because of the end anchor. A line containing a phone number produces no ZIP match because the lookbehind and anchor both fail. 1 Harbor Way, Boston MA 02134-1729 zip5=02134 plus4=1729 12345 Sunset Blvd, Los Angeles CA no match — anchor saves it … Austin TX tel 512-555-0142 no match — lookbehind saves it Both guards are cheap and both prevent a confidently wrong extraction rather than a visible failure.

The middle case is the one that motivates the end anchor most clearly. A five-digit street number is common in western US cities, and a pattern that searches anywhere in the line will happily return 12345 as the postal code — producing a record that looks complete and routes to the wrong state.

Vectorised Extraction

import pandas as pd


def add_zip_columns(df: pd.DataFrame, col: str = "address_line") -> pd.DataFrame:
    """Extract zip5 and plus4 for a whole frame, preserving leading zeros."""
    cleaned = df[col].fillna("").str.strip().str.replace(r"\s+", " ", regex=True)
    parts = cleaned.str.extract(ZIP_RE)          # both named groups become columns

    out = df.copy()
    out["zip5"] = parts["zip5"].astype("string")     # nullable string, not int
    out["plus4"] = parts["plus4"].astype("string")
    out["zip_full"] = out["zip5"].str.cat(out["plus4"], sep="-", na_rep="").str.rstrip("-")
    return out

Declaring the dtype explicitly is the defensive step that matters most here. Pandas will happily infer an integer dtype from a column of five-digit strings if given the chance, and astype("string") after extraction removes any possibility of that inference happening downstream in a to_parquet or to_sql round trip.

What the ZIP Is Good For Beyond Delivery

The postal code earns its place in a pipeline well beyond addressing the envelope, and each downstream use has a different tolerance for a missing add-on.

The most common secondary use is a coarse geographic key. A five-digit code is enough to bucket records by region, to pick a routing provider, and to select a distance threshold for outlier detection, all without any coordinate at all. That makes ZIP extraction one of the cheapest useful signals in the whole pipeline, and worth doing even for records you never intend to geocode.

The second is as a validation cross-check. A geocoded coordinate that falls outside the polygon of the postcode the record claims is a strong signal that something is wrong — a transposed pair, a same-named city in another state, or a mis-parsed street. Postcode polygons are widely available and the check is a single spatial predicate, which makes it a much cheaper first line of defence than a full accuracy corpus.

The third is blocking for deduplication. A postal code is a natural blocking key, and because it is short and exact it partitions a large file cheaply. The limitation is that a typo in the postcode places a record in the wrong block entirely, which is precisely why geohash blocking is preferred once coordinates exist.

Each of these uses tolerates a missing add-on without complaint, which is worth remembering when deciding how hard to work at obtaining one. The add-on matters for postal discounts and for delivery-point precision; for everything else, the five-digit base is sufficient.

Edge Cases and Failure Modes

Nine digits with no separator. 021341729 occurs in fixed-width exports. Handle it with a separate branch that splits at five characters rather than by relaxing the main pattern, which would otherwise start matching arbitrary long digit runs.

ZIP codes in non-US addresses. A five-digit token at the end of a German or French address will match this pattern happily. Route by country before applying it — the ambiguity is discussed in international address format standardization.

Add-on of 0000. Some systems pad a missing add-on with zeros rather than leaving it empty. Treat 0000 as absent, because it is never a valid delivery-point segment and storing it asserts a precision that does not exist.

Military and territory codes. APO, FPO and DPO addresses carry ordinary five-digit ZIPs in dedicated ranges, and territories such as Puerto Rico and Guam have their own. The pattern handles all of them; it is the downstream state-matching logic that usually does not.

What the Add-On Encodes

The four extra digits are not a checksum or a sequence number; they identify a delivery segment, and knowing which kind explains why they cannot be inferred.

What a ZIP+4 add-on identifies Four segment types. One side of a street block between two intersections. A single floor of a multi-tenant building. A contiguous bank of post office boxes. A single high-volume recipient such as a large employer, which may hold its own unique five-digit code as well. one side of a block between two intersections, odd or even numbers the most common case in residential data a floor or a tenant within one multi-tenant commercial building why the unit and the add-on must agree a bank of PO boxes a contiguous range within one post office changes when boxes are renumbered a single large recipient a university, hospital or corporate campus may also hold a unique five-digit code

Integration Note

ZIP extraction runs on the city line rather than the delivery line, which is why it is anchored separately from the unit designator extractor and the street parser. In a well-ordered pipeline the address is split into lines first, each line is parsed by the pattern that belongs to it, and no pattern ever has to defend against tokens from a line it should not have seen.