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
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
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.
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.
Related
- Regex Patterns for US Address Parsing — the full US parsing workflow.
- How to Parse Street Numbers and Suffixes With Regex — the delivery-line parser this one complements.
- USPS CASS Certification Guidelines — validating the add-on rather than trusting it.