Tutorial · JavaScript, Python, PHP

Postal code regex for 20 countries, tested

A regex catches typos in postal codes; a lookup catches codes that do not exist. Tested patterns for 20 countries, the traps in each language, and the lookup for the rest.

Updated October 29, 2026 · 8 min read

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

CountryPatternExamplesReal codes matched
United States^\d{5}(?:[- ]\d{4})?$10001, 10001-206241,488 of 41,488
United Kingdom^[A-Z]{1,2}\d[A-Z\d]? ?\d[ABD-HJLNP-UW-Z]{2}$SW1A 1AA, M1 1AE1,839,678 of 1,839,678
Canada^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$K1A 0B1899,779 of 899,779
Germany^\d{5}$10115, 0106710,815 of 10,815
France^\d{5}$7500820,414 of 20,414
Netherlands^[1-9]\d{3} ?(?!SA|SD|SS)[A-Z]{2}$1012 AB468,579 of 468,579
India^[1-9]\d{5}$11000119,238 of 19,238
Brazil^\d{5}-?\d{3}$01310-1005,525 of 5,525
Japan^\d{3}-?\d{4}$100-0001142,577 of 142,577
Australia^\d{4}$2000, 08003,171 of 3,171
Spain^(?:0[1-9]|[1-4]\d|5[0-2])\d{3}$2801311,150 of 11,150
Italy^\d{5}$001844,736 of 4,736
Poland^\d{2}-\d{3}$00-95020,299 of 20,299
Portugal^\d{4}-\d{3}$1000-001197,772 of 197,772
Sweden^[1-9]\d{2} ?\d{2}$111 2218,887 of 18,887
Switzerland^[1-9]\d{3}$80013,362 of 3,362
Austria^[1-9]\d{3}$10102,501 of 2,501
Ireland (Eircode)^(?:[AC-FHKNPRTV-Y]\d{2}|D6W) ?[0-9AC-FHKNPRTV-Y]{4}$A65 F4E2139 of 139 routing keys
Mexico^\d{5}$0660032,448 of 32,448
South Korea^\d{5}$0318734,249 of 34,249

Letters are matched case-insensitively (the i flag in the code below). What some patterns encode:

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

MistakeWhat happensFix
$ 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 codere.ASCII, or [0-9]
No ^ or $"123456" passes \d{5}Anchor both ends
\s instead of a spaceA tab or line break between the parts passesUse a literal space, after normalizing the input
One pattern for all countriesValid foreign codes are rejectedOne 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.)

FieldMeaning
validThe code exists in that country
format_validThe code has the country’s format; null where no format is known
uses_postal_codesfalse for countries without postal codes, such as Hong Kong: hide the field
normalizedThe code as stored, with the canonical spacing and case. Save this instead of the user’s input
matchesThe 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.

Frequently asked questions

Is there one regex for all postal codes?

No. Formats range from three digits (Iceland) to letter-digit mixes (UK, Canada) and eight digits (Brazil), and some places, such as Hong Kong, have no postal codes at all. Validate per country, and let unknown countries through rather than block the form.

What is the regex for a US ZIP code?

^\d{5}(?:[- ]\d{4})?$ accepts five-digit ZIP codes and ZIP+4 (10001-2062). It matched all 41,488 US codes in GeoNames' postal code file.

What is the regex for a UK postcode?

^[A-Z]{1,2}\d[A-Z\d]? ?\d[ABD-HJLNP-UW-Z]{2}$, case-insensitive. It matched all 1,839,678 postcodes in GeoNames' full UK file. It does not cover the special codes GIR 0AA and BFPO.

What is the regex for a Canadian postal code?

^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$, case-insensitive. Canadian codes never use D, F, I, O, Q or U, and W and Z never come first. It matched all 899,779 codes in GeoNames' full Canada file.

Why does my postal code regex accept a trailing line break?

In Python and PHP, $ also matches before a final line break, so ^\d{5}$ accepts "10001\n". Use re.fullmatch in Python and the D modifier in PHP. JavaScript's $ does not have this problem unless you set the m flag.

Can a regex tell me whether a postal code exists?

No. 00000 has the US format but is not a ZIP code. Only a lookup in a postal code database, such as the /v1/validate endpoint of the zipcodestack ZIP code validation API, tells real codes from well-formed fake ones.

Postal code API

Get this data in your app

zipcodestack looks up, validates and searches postal codes in 182 countries: city, state, coordinates, distances and radius search, as JSON.

/v1/search documentation International postal code API All documentation

1,000 free requests every month. No credit card required.

GET https://api.zipcodestack.com/v1/search?codes=10001&country=us

{
  "results": {
    "10001": [{
      "postal_code": "10001",
      "country_code": "US",
      "latitude": 40.7484,
      "longitude": -73.9967,
      "city": "New York",
      "state": "New York",
      "state_code": "NY",
      "province": "New York"
    }]
  }
}

Keep reading

Start using our ZIP code API for free today!

Get 1,000 requests / month for free