A postal code regex checks that a code has the right shape for its country: five digits in the US, A1A 1A1 in Canada, four digits and two letters in the Netherlands. There is no single pattern for every country, so you keep one per country and pick it by the country the user selected. Below are patterns for 20 countries with code in JavaScript, Python and PHP. Each pattern was run in all three languages against every code of its country in GeoNames’ postal code files (3,776,807 codes in total) and against a list of wrong inputs, and all three gave the expected answer every time.
1. Postal code regex by country
| Country | Pattern | Examples | Real codes matched |
|---|---|---|---|
| United States | ^\d{5}(?:[- ]\d{4})?$ | 10001, 10001-2062 | 41,488 of 41,488 |
| United Kingdom | ^[A-Z]{1,2}\d[A-Z\d]? ?\d[ABD-HJLNP-UW-Z]{2}$ | SW1A 1AA, M1 1AE | 1,839,678 of 1,839,678 |
| Canada | ^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$ | K1A 0B1 | 899,779 of 899,779 |
| Germany | ^\d{5}$ | 10115, 01067 | 10,815 of 10,815 |
| France | ^\d{5}$ | 75008 | 20,414 of 20,414 |
| Netherlands | ^[1-9]\d{3} ?(?!SA|SD|SS)[A-Z]{2}$ | 1012 AB | 468,579 of 468,579 |
| India | ^[1-9]\d{5}$ | 110001 | 19,238 of 19,238 |
| Brazil | ^\d{5}-?\d{3}$ | 01310-100 | 5,525 of 5,525 |
| Japan | ^\d{3}-?\d{4}$ | 100-0001 | 142,577 of 142,577 |
| Australia | ^\d{4}$ | 2000, 0800 | 3,171 of 3,171 |
| Spain | ^(?:0[1-9]|[1-4]\d|5[0-2])\d{3}$ | 28013 | 11,150 of 11,150 |
| Italy | ^\d{5}$ | 00184 | 4,736 of 4,736 |
| Poland | ^\d{2}-\d{3}$ | 00-950 | 20,299 of 20,299 |
| Portugal | ^\d{4}-\d{3}$ | 1000-001 | 197,772 of 197,772 |
| Sweden | ^[1-9]\d{2} ?\d{2}$ | 111 22 | 18,887 of 18,887 |
| Switzerland | ^[1-9]\d{3}$ | 8001 | 3,362 of 3,362 |
| Austria | ^[1-9]\d{3}$ | 1010 | 2,501 of 2,501 |
| Ireland (Eircode) | ^(?:[AC-FHKNPRTV-Y]\d{2}|D6W) ?[0-9AC-FHKNPRTV-Y]{4}$ | A65 F4E2 | 139 of 139 routing keys |
| Mexico | ^\d{5}$ | 06600 | 32,448 of 32,448 |
| South Korea | ^\d{5}$ | 03187 | 34,249 of 34,249 |
Letters are matched case-insensitively (the i flag in the code below). What some patterns encode:
- United Kingdom: the last two letters never include C, I, K, M, O or V. The pattern still accepts outward codes that don’t exist, and it leaves out the special codes
GIR 0AAandBFPO. - Canada: D, F, I, O, Q and U are never used, and W and Z never come first.
- Netherlands: the number never starts with 0, and the letter pairs SA, SD and SS are not used.
- Spain: the first two digits are the province, 01 to 52, so
53001fails. - France: business addresses add “CEDEX” after the code (
75008 CEDEX 08). Validate the five digits. - Ireland: an Eircode is a routing key (a letter and two digits, or
D6W) and a four-character identifier. GeoNames only lists the 139 routing keys, so the identifier part was tested with examples. - South Korea: five digits; the old six-digit form (
123-456) fails. - Country prefixes such as
D-10115orCH-8001fail everywhere. Strip them first if your users type them.
Each country page lists the official pattern from Google’s address data, which is stricter for some countries; for example, see UK postcodes or US ZIP codes.
2. JavaScript
// Postal code formats by ISO country code. Tested against GeoNames' postal code files.
export const POSTAL_CODE_PATTERNS = {
US: /^\d{5}(?:[- ]\d{4})?$/,
GB: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[ABD-HJLNP-UW-Z]{2}$/i,
CA: /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$/i,
DE: /^\d{5}$/,
FR: /^\d{5}$/,
NL: /^[1-9]\d{3} ?(?!SA|SD|SS)[A-Z]{2}$/i,
IN: /^[1-9]\d{5}$/,
BR: /^\d{5}-?\d{3}$/,
JP: /^\d{3}-?\d{4}$/,
AU: /^\d{4}$/,
ES: /^(?:0[1-9]|[1-4]\d|5[0-2])\d{3}$/,
IT: /^\d{5}$/,
PL: /^\d{2}-\d{3}$/,
PT: /^\d{4}-\d{3}$/,
SE: /^[1-9]\d{2} ?\d{2}$/,
CH: /^[1-9]\d{3}$/,
AT: /^[1-9]\d{3}$/,
IE: /^(?:[AC-FHKNPRTV-Y]\d{2}|D6W) ?[0-9AC-FHKNPRTV-Y]{4}$/i,
MX: /^\d{5}$/,
KR: /^\d{5}$/,
};
export function isValidPostalCodeFormat(code, country) {
const pattern = POSTAL_CODE_PATTERNS[country.toUpperCase()];
if (!pattern) return true; // no pattern for this country: don't block the form
return pattern.test(String(code).trim().replace(/\s+/g, ' '));
}
The function trims the input and turns runs of spaces into one, so isValidPostalCodeFormat(' sw1a 1aa ', 'gb') returns true. isValidPostalCodeFormat('1234 SA', 'NL') and isValidPostalCodeFormat('D-10115', 'DE') return false.
3. Python
import re
# Postal code formats by ISO country code. Tested against GeoNames' postal code files.
POSTAL_CODE_PATTERNS = {
"US": r"^\d{5}(?:[- ]\d{4})?$",
"GB": r"^[A-Z]{1,2}\d[A-Z\d]? ?\d[ABD-HJLNP-UW-Z]{2}$",
"CA": r"^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$",
"DE": r"^\d{5}$",
"FR": r"^\d{5}$",
"NL": r"^[1-9]\d{3} ?(?!SA|SD|SS)[A-Z]{2}$",
"IN": r"^[1-9]\d{5}$",
"BR": r"^\d{5}-?\d{3}$",
"JP": r"^\d{3}-?\d{4}$",
"AU": r"^\d{4}$",
"ES": r"^(?:0[1-9]|[1-4]\d|5[0-2])\d{3}$",
"IT": r"^\d{5}$",
"PL": r"^\d{2}-\d{3}$",
"PT": r"^\d{4}-\d{3}$",
"SE": r"^[1-9]\d{2} ?\d{2}$",
"CH": r"^[1-9]\d{3}$",
"AT": r"^[1-9]\d{3}$",
"IE": r"^(?:[AC-FHKNPRTV-Y]\d{2}|D6W) ?[0-9AC-FHKNPRTV-Y]{4}$",
"MX": r"^\d{5}$",
"KR": r"^\d{5}$",
}
# re.ASCII: without it, \d also matches other scripts' digits, such as "١٢٣٤٥".
_COMPILED = {
country: re.compile(pattern, re.ASCII | re.IGNORECASE)
for country, pattern in POSTAL_CODE_PATTERNS.items()
}
def is_valid_postal_code_format(code: str, country: str) -> bool:
pattern = _COMPILED.get(country.upper())
if pattern is None:
return True # no pattern for this country: don't block the form
return pattern.fullmatch(" ".join(code.split())) is not None
Two details matter in Python. fullmatch instead of match: with re.match, the $ in ^\d{5}$ also matches before a final line break, so "10001\n" passes. And re.ASCII: without it, \d accepts Arabic-Indic digits, and "١٠٠٠١" passes the US pattern.
4. PHP
<?php
declare(strict_types=1);
// Postal code formats by ISO country code. Tested against GeoNames' postal code files.
// D: $ only matches at the very end; i: lower case letters are fine.
const POSTAL_CODE_PATTERNS = [
'US' => '/^\d{5}(?:[- ]\d{4})?$/D',
'GB' => '/^[A-Z]{1,2}\d[A-Z\d]? ?\d[ABD-HJLNP-UW-Z]{2}$/Di',
'CA' => '/^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$/Di',
'DE' => '/^\d{5}$/D',
'FR' => '/^\d{5}$/D',
'NL' => '/^[1-9]\d{3} ?(?!SA|SD|SS)[A-Z]{2}$/Di',
'IN' => '/^[1-9]\d{5}$/D',
'BR' => '/^\d{5}-?\d{3}$/D',
'JP' => '/^\d{3}-?\d{4}$/D',
'AU' => '/^\d{4}$/D',
'ES' => '/^(?:0[1-9]|[1-4]\d|5[0-2])\d{3}$/D',
'IT' => '/^\d{5}$/D',
'PL' => '/^\d{2}-\d{3}$/D',
'PT' => '/^\d{4}-\d{3}$/D',
'SE' => '/^[1-9]\d{2} ?\d{2}$/D',
'CH' => '/^[1-9]\d{3}$/D',
'AT' => '/^[1-9]\d{3}$/D',
'IE' => '/^(?:[AC-FHKNPRTV-Y]\d{2}|D6W) ?[0-9AC-FHKNPRTV-Y]{4}$/Di',
'MX' => '/^\d{5}$/D',
'KR' => '/^\d{5}$/D',
];
function isValidPostalCodeFormat(string $code, string $country): bool
{
$pattern = POSTAL_CODE_PATTERNS[strtoupper($country)] ?? null;
if ($pattern === null) {
return true; // no pattern for this country: don't block the form
}
return preg_match($pattern, preg_replace('/\s+/', ' ', trim($code))) === 1;
}
Without the D modifier, preg_match('/^\d{5}$/', "10001\n") returns 1. PHP’s \d only matches ASCII digits as long as the pattern has no u modifier.
5. Mistakes that let bad codes through
| Mistake | What happens | Fix |
|---|---|---|
$ in Python or PHP | "10001\n" passes ^\d{5}$ | re.fullmatch in Python, the D modifier in PHP |
\d in Python | "١٠٠٠١" passes as a US ZIP code | re.ASCII, or [0-9] |
No ^ or $ | "123456" passes \d{5} | Anchor both ends |
\s instead of a space | A tab or line break between the parts passes | Use a literal space, after normalizing the input |
| One pattern for all countries | Valid foreign codes are rejected | One pattern per country, and let unknown countries through |
6. When a regex is not enough
A regex only checks the shape. 00000 has the US format but is not a ZIP code, and in Austria 1999 has the right format but is not assigned. To know that a code is real, and to fill in the city and state, look it up with the validation endpoint:
curl -G https://api.zipcodestack.com/v1/validate \
-d code=1010 \
-d country=at \
-H "apikey: YOUR-API-KEY"
{
"query": { "code": "1010", "country": "AT" },
"result": {
"valid": true,
"format_valid": true,
"uses_postal_codes": true,
"normalized": "1010",
"matches": [
{
"postal_code": "1010",
"country_code": "AT",
"latitude": 48.2077,
"longitude": 16.3705,
"city": "Wien, Innere Stadt",
"state": "Wien",
"state_code": "09",
"accuracy": 4
}
]
}
}
(Shortened: each match also has city_en, state_en, province, community and their codes.)
| Field | Meaning |
|---|---|
valid | The code exists in that country |
format_valid | The code has the country’s format; null where no format is known |
uses_postal_codes | false for countries without postal codes, such as Hong Kong: hide the field |
normalized | The code as stored, with the canonical spacing and case. Save this instead of the user’s input |
matches | The places the code belongs to, with coordinates |
{"valid": false, "format_valid": true} usually means one wrong digit; {"valid": false, "format_valid": false} means the input is not a postal code of that country.
In JavaScript (Node.js 18 or later), with the key in an environment variable:
const API_URL = 'https://api.zipcodestack.com/v1/validate';
export async function validatePostalCode(code, country) {
const params = new URLSearchParams({ code, country });
const response = await fetch(`${API_URL}?${params}`, {
headers: { apikey: process.env.ZIPCODESTACK_KEY },
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
// 401: API key, 422: invalid parameters, 429: quota or rate limit
throw new Error(`zipcodestack ${response.status}: ${body.message ?? response.statusText}`);
}
return body.result; // valid, format_valid, uses_postal_codes, normalized, matches
}
import { validatePostalCode } from './validate-postal-code.mjs';
const result = await validatePostalCode(' 1010 ', 'at');
if (!result.uses_postal_codes) {
console.log('This country has no postal codes: hide the field');
} else if (result.valid) {
const { city, state } = result.matches[0];
console.log(`Save ${result.normalized}, prefill ${city}, ${state}`);
} else if (result.format_valid) {
console.log('Right format, but this code does not exist. Check the digits.');
} else {
console.log('This is not a postal code of that country.');
}
In Python, with requests:
import os
import requests
API_URL = "https://api.zipcodestack.com/v1/validate"
def validate_postal_code(code: str, country: str) -> dict:
response = requests.get(
API_URL,
params={"code": code, "country": country},
headers={"apikey": os.environ["ZIPCODESTACK_KEY"]},
timeout=10,
)
# 401: API key, 422: invalid parameters, 429: quota or rate limit
response.raise_for_status()
return response.json()["result"]
result = validate_postal_code("1999", "at")
print(result["valid"], result["format_valid"]) # False True: well-formed, but not assigned
Both were run against a local server that answers with the documentation’s examples. Each call to /v1/validate counts as 1 request; the free plan has 1,000 requests a month for testing and personal projects (pricing). Run the regex first, in the browser, and call the API only for codes that pass it, from your server so the key stays private.
To check a single code without code, use the free postal code validator.