ZIP codes look simple โ five digits, sometimes four more โ but the system is more structured than it appears, and getting the validation and geographic-mapping rules wrong causes real bugs. This guide covers the structure of US ZIP codes, how the first digit encodes a broad region of the country, how ZIP+4 works, the most useful validation patterns in JavaScript, Python and regex, and which edge cases your code should handle gracefully.
A short history
The Zone Improvement Plan (ZIP) was introduced by the United States Postal Service (USPS) in 1963 to speed mail sorting. The original 5-digit code defined a delivery area. ZIP+4, added in 1983, identifies a block face or a high-volume receiver such as a business or government office. Most consumer-facing code only needs the 5-digit version, but ignoring ZIP+4 entirely is the most common validation bug.
Structure of a 5-digit ZIP
The five digits of a standard ZIP are not random. They are a hierarchical address:
- Digit 1 โ national area: one of nine broad geographic regions. The northeast starts at 0, the west coast tops out at 9.
- Digits 2โ3 โ sectional center facility (SCF): a sortation center that serves a cluster of post offices.
- Digits 4โ5 โ delivery area: a specific post office or delivery unit within the SCF.
The first digit alone tells you the broad region. The first three digits (the "ZIP prefix") reliably map to a state, which is the property our ZIP Lookup tool uses to identify the state of a ZIP code.
First-digit regions
| First digit | Region | Example states |
|---|---|---|
| 0 | New England & Puerto Rico / VI | CT, MA, ME, NH, NJ, RI, VT, PR |
| 1 | Mid-Atlantic | DE, NY, PA, Washington DC |
| 2 | Southeast / Appalachia | NC, SC, VA, WV, MD, DC |
| 3 | Southeast | AL, FL, GA, MS, TN, KY |
| 4 | Midwest / Great Lakes | OH, IN, MI, WI, IL |
| 5 | Upper Midwest / Plains | IA, MN, MT, ND, SD, NE, KS, MO, WI |
| 6 | South-Central | AR, LA, OK, TX, NM |
| 7 | South / South-Central | OK, TX, AR, LA |
| 8 | Mountain West | AZ, CO, ID, NM, NV, UT, WY |
| 9 | West Coast & Pacific territories | CA, OR, WA, AK, HI, GU, AS |
Note that some state ZIP prefixes straddle region boundaries โ Kentucky has both 4 and 4, for example. The reliable signal for "does this ZIP belong to state X" remains the first three digits, not the first digit alone.
ZIP+4 โ what the extra four digits mean
ZIP+4 takes the 5-digit ZIP and adds a hyphen plus four more digits:
- +2 digits: a specific city block or group of streets
- +2 digits: the actual segment of that block, or a specific large receiver
Most modern address forms accept either five digits or the full ZIP+4. USPS prefers ZIP+4 because it lets automated sorters route mail directly to the carrier route, but the legacy 5-digit ZIP is still universally valid.
Validation in regex
The simplest robust pattern allows five digits, optionally followed by a hyphen and four digits:
^\d{5}(?:-\d{4})?$
This pattern accepts 90210 and 90210-1234 while rejecting padding, spaces, and ZIPs with weird characters. A common mistake is to require ZIP+4 unconditionally, which breaks every signup form that autofilled an address from a Chrome save.
Validation in JavaScript
const ZIP_RE = /^\d{5}(?:-\d{4})?$/;
function normalizeZip(input) {
const zip = String(input).trim();
if (!ZIP_RE.test(zip)) return null;
return zip.length === 10 ? zip : zip.slice(0, 5);
}
normalizeZip(" 90210-1234 "); // "90210-1234"
normalizeZip("90210"); // "90210"
normalizeZip("9021"); // null
normalizeZip("90210 1234"); // null (space is not allowed)
The normalizeZip helper returns either the canonical form or null, which makes routing in your UI easy: if it returns null, show the inline error; otherwise pass the normalized value to your API.
Validation in Python
import re
ZIP_RE = re.compile(r"^\d{5}(?:-\d{4})?$")
def normalize_zip(value: str) -> str | None:
zip_code = value.strip()
if not ZIP_RE.match(zip_code):
return None
return zip_code[:5] if len(zip_code) == 5 else zip_code
If you need to validate-and-geocode in one step, the USA Data Tools address API returns the matching state for any well-formed ZIP, so you can reject ZIPs that do not match any state without shipping your own prefix table.
Mapping ZIP to state
The first three digits map to a USPS SCF, and that SCF maps to one or more states. A practical lookup table is large enough that you should not hand-maintain it โ instead either use the ZIP Lookup tool or the address API:
async function stateForZip(zip) {
const r = await fetch(
`/us-address/api/v1/zip-lookup?zip=${encodeURIComponent(zip)}`,
{ headers: { Authorization: `Bearer ${KEY}` } }
);
if (!r.ok) return null;
const { state } = await r.json();
return state;
}
Edge case worth knowing: a few ZIPs are genuinely ambiguous โ Joint Federal Photography zip codes, ZIPs assigned to large businesses that span a state border. In your form design, prefer asking for both ZIP and state, then validating that they agree. That catches both typos and the rare ambiguous case.
Military and territory ZIPs
Your validator should accept these even though they look unusual:
- US military APO/FPO/DPO: ZIPs in the 340xx range with state abbreviation
AA,AE, orAP. These are overseas military addresses shipped via USPS. - Puerto Rico: ZIPs in the 006xxโ009xx range, state abbreviation
PR. Sometimes accepted only with PR rather than the spelled-out "Puerto Rico". - US Virgin Islands: 008xx, state
VI. - Guam, American Samoa, Northern Mariana: 969xx range with state
GU,AS, orMP.
Shipping logic that rejects these is one of the most common (and easiest-to-fix) bugs in e-commerce.
Common validation mistakes
- Trimming the input. Mobile autofill frequently adds a leading or trailing space โ strip whitespace before validating.
- Integer type. Storing ZIPs as integers drops the leading zero, so every New Hampshire (030xx) and Puerto Rico (006xx) ZIP is corrupted. ZIPs are strings, always.
- Forcing ZIP+4. Asking the user for the extended code on a signup form is friction that buys you very little.
- Not rejecting fake test ZIPs like
00000and12345. Some ZIPs are real (12345 is General Electric in Schenectady, NY) and some look valid but are not assigned โ use a lookup table or the API to reject unassigned ones. - Routing on the first digit instead of the prefix. The first digit does not uniquely identify a state.
Tip: when you generate fake addresses, make sure the ZIP-to-state pair is internally consistent. Random Five digits paired with a random state will fail your own downstream validation. Our address generator selects the ZIP from a per-state prefix list, which is why a generated "California" address always comes out with a 90xxxโ96xxx ZIP.
Cheat sheet you can paste in code reviews
// Accept: 5 digits, optionally ZIP+4
const ZIP_RE = /^\d{5}(?:-\d{4})?$/;
// Never: ZIPs as integers
// Never: maxlength=5 on the input (breaks ZIP+4 paste)
// Never: require ZIP+4 on a public form
// Always: trim whitespace, store as string, validate against state
Wrapping up
ZIP codes are a small piece of data with surprisingly subtle rules. The five-digit version is enough for most applications; ZIP+4 is a nice-to-have. The first three digits are the smallest prefix that reliably maps to a state. Validate the format locally and verify the ZIP-to-state relationship โ ideally against a maintained lookup or the USA Data Tools API โ before trusting the value downstream. Get these right and your address forms will stop shipping edge-case bugs.