Postal Code Validation by Country
How do you validate a postal code field that must accept SW1A 1AA, 02134, K1A 0B1, 1012 AB and D02 X285 — and nothing obviously wrong — while not blocking users in countries you have no rule for? This recipe keeps a per-country table of patterns, examples and local labels, normalises case and spacing before testing, changes the field’s label and requirement with the selected country, and reports failures through setCustomValidity() in the site’s standard Constraint Validation API flow. Anything without a rule is accepted as typed.
When to Use Per-Country Postcode Rules
A postcode rule is worth having when it is cheap to maintain and catches real mistakes. The big win is in countries with structured, alphanumeric codes, where users often drop the space, mix letter O and zero, or type a code from the wrong country.
- Add a rule for the countries that make up most of your orders; a dozen entries usually cover almost all traffic.
- Accept as typed for every other country. An unknown country with a missing rule is not a reason to block an order.
- Hide or make optional the field for countries that do not use postal codes.
Pattern validation only checks shape. Whether SW1A 1AA is a real, deliverable postcode is a separate question answered by an address lookup, covered in validating autocompleted address fields.
Minimal Working Postcode Validator
type CountryCode = "GB" | "US" | "CA" | "NL" | "IE" | "DE" | "FR" | "AU" | "JP" | "HK" | string;
interface PostcodeRule {
label: string; // what the field is called locally
pattern: RegExp; // tested against the normalised value
example: string; // shown in the hint and the error
normalise?: (v: string) => string;
}
const insertSpaceBeforeLast3 = (v: string) => (v.length > 3 && !v.includes(" ") ? `${v.slice(0, -3)} ${v.slice(-3)}` : v);
export const POSTCODE_RULES: Record<string, PostcodeRule | null> = {
GB: { label: "Postcode", pattern: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/, example: "SW1A 1AA", normalise: insertSpaceBeforeLast3 },
US: { label: "ZIP code", pattern: /^\d{5}(-\d{4})?$/, example: "02134" },
CA: { label: "Postal code", pattern: /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] \d[ABCEGHJ-NPRSTV-Z]\d$/, example: "K1A 0B1", normalise: insertSpaceBeforeLast3 },
NL: { label: "Postcode", pattern: /^[1-9]\d{3} [A-Z]{2}$/, example: "1012 AB", normalise: (v) => v.replace(/^(\d{4}) ?([A-Z]{2})$/, "$1 $2") },
IE: { label: "Eircode", pattern: /^(?:[AC-FHKNPRTV-Y]\d{2}|D6W) [0-9AC-FHKNPRTV-Y]{4}$/, example: "D02 X285", normalise: (v) => v.replace(/^(\w{3}) ?(\w{4})$/, "$1 $2") },
DE: { label: "Postleitzahl", pattern: /^\d{5}$/, example: "10115" },
FR: { label: "Code postal", pattern: /^\d{5}$/, example: "75008" },
AU: { label: "Postcode", pattern: /^\d{4}$/, example: "2000" },
JP: { label: "Postal code", pattern: /^\d{3}-\d{4}$/, example: "100-0001", normalise: (v) => v.replace(/^(\d{3})-?(\d{4})$/, "$1-$2") },
HK: null, // no postal codes
};
export function normalisePostcode(raw: string, country: CountryCode): string {
const base = raw.normalize("NFKC").toUpperCase().trim().replace(/\s+/g, " ");
const rule = POSTCODE_RULES[country];
return rule?.normalise ? rule.normalise(base.replace(/ /g, "")) : base;
}
export function postcodeError(raw: string, country: CountryCode, required: boolean): string {
const rule = POSTCODE_RULES[country];
if (rule === null) return ""; // country without postcodes
const value = normalisePostcode(raw, country);
if (value === "") return required ? `Enter your ${(rule?.label ?? "postcode").toLowerCase()}.` : "";
if (!rule) return ""; // no rule: accept as typed
return rule.pattern.test(value) ? "" : `Enter a ${rule.label.toLowerCase()} like ${rule.example}.`;
}
// Wiring
const form = document.querySelector<HTMLFormElement>("#address")!;
const countrySel = form.querySelector<HTMLSelectElement>("#country")!;
const field = form.querySelector<HTMLInputElement>("#postcode")!;
const label = form.querySelector<HTMLLabelElement>("label[for=postcode]")!;
const hint = form.querySelector<HTMLElement>("#postcode-hint")!;
const wrapper = field.closest<HTMLElement>(".field")!;
function applyCountry(): void {
const rule = POSTCODE_RULES[countrySel.value];
wrapper.hidden = rule === null; // hide for countries without postcodes
field.disabled = rule === null; // disabled fields are skipped by validation
field.required = Boolean(rule);
label.textContent = rule?.label ?? "Postcode (if you have one)";
hint.textContent = rule ? `For example, ${rule.example}` : "";
field.setCustomValidity(postcodeError(field.value, countrySel.value, field.required));
}
countrySel.addEventListener("change", applyCountry);
field.addEventListener("input", () => field.setCustomValidity(postcodeError(field.value, countrySel.value, field.required)));
field.addEventListener("blur", () => {
if (POSTCODE_RULES[countrySel.value]) field.value = normalisePostcode(field.value, countrySel.value);
});
form.addEventListener("submit", (event) => {
applyCountry();
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
applyCountry();
Normalisation runs before the pattern test, so the patterns can be strict about spacing while users can type sw1a1aa, SW1A 1AA or sw1a 1aa. Disabling the hidden field for countries without postcodes matters: disabled controls are barred from constraint validation and excluded from submission, so a stale required cannot block the form.
Postcode Rule Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
label |
string |
“Postcode” | Local name shown on the label and in errors |
pattern |
RegExp |
— | Anchored test against the normalised value |
example |
string |
— | Used in the hint and the error message |
normalise |
(v) => string |
uppercase, collapse spaces | Country-specific spacing and separators |
Rule value null |
sentinel | — | Country has no postcodes: hide and disable the field |
| Missing rule | undefined |
— | Accept any value as typed |
autocomplete |
token | postal-code |
Autofill from the browser profile |
Verification Steps
import { describe, it, expect } from "vitest";
import { postcodeError, normalisePostcode } from "./postcode";
describe("postcodes", () => {
it.each([
["GB", "sw1a1aa"], ["GB", "M1 1AE"], ["US", "02134"], ["US", "02134-1234"],
["CA", "k1a0b1"], ["NL", "1012ab"], ["IE", "d02x285"], ["JP", "1000001"],
])("%s accepts %s", (cc, v) => expect(postcodeError(v, cc, true)).toBe(""));
it("normalises spacing for GB", () => expect(normalisePostcode("sw1a1aa", "GB")).toBe("SW1A 1AA"));
it("rejects a US code with four digits", () => expect(postcodeError("2134", "US", true)).toMatch(/02134/));
it("accepts anything when no rule exists", () => expect(postcodeError("70040-010", "BR", true)).toBe(""));
});
Edge Cases and Failure Modes
Leading zeros. Many US ZIP codes and French codes start with zero. Any path that converts the value to a number — type="number", parseInt, a spreadsheet import — silently corrupts them. Keep postcodes as strings end to end.
Overseas territories and special codes. British Forces addresses (BFPO), Crown dependencies such as Jersey (JE2 3AB, which does match the GB shape) and French overseas departments (97400) have their own conventions. If your pattern rejects them, customers there cannot order; test with a list of real edge-case codes before shipping a rule.
Pattern too clever. A GB regex that encodes every valid outward code is long, hard to review and still does not prove deliverability. Prefer a shape check plus an address lookup over a regex that tries to be a postal database.
Changing the label without updating the error. If the label becomes “ZIP code” but the error still says “postcode”, users are told about a field they cannot see. Build both from the same rule entry, as the implementation does.
Keeping the Rule Table Reviewable
A postcode table grows one country at a time, usually in response to a support ticket, and each addition is a chance to lock out customers. Treat it like code that ships to production, because it does. Each entry should arrive with at least three real example codes in the test file — including the country’s awkward cases — so a reviewer can see the pattern exercised rather than trusting the regex by eye. Keep the patterns shape-only and short enough to read; a pattern that needs a comment to explain it is probably trying to encode postal data that belongs in a lookup service. And log, without the value itself, how often each country’s rule rejects a submission: a sudden spike after a deploy is the fastest signal that a new pattern is too strict. The same review discipline applies to the regular expressions in preventing ReDoS in validation regex, where an innocent-looking pattern can also hang the server.
Positioning the Postcode Field in the Form
Where the postcode sits changes how useful validation is. In the United Kingdom, the Netherlands and Ireland the postcode (or Eircode) nearly identifies the street, so many forms ask for it first and look the address up from it; validating the shape immediately, before the lookup call, saves a request and gives an instant, specific error. In the United States the ZIP code conventionally comes after city and state, and moving it earlier confuses users. The robust compromise is to follow each country’s convention by reordering fields when the country changes, which also means validation messages must refer to fields by their label, not by position. Reordering with CSS order keeps the DOM stable for autofill but breaks the relationship between visual and reading order, which fails WCAG 1.3.2; reorder the DOM itself, and keep focus on the country selector while it happens.
Frequently Asked Questions
Can a single regex validate postcodes for every country?
No. Shapes range from four digits to seven alphanumeric characters with a space, and some countries have no postcodes at all. Keep a per-country table and accept values as typed for countries without a rule.
Should postcode inputs use type number?
Never. Number inputs drop leading zeros and reject letters, which breaks US, French, British, Canadian and Dutch codes. Use a text input with autocomplete="postal-code".
Does a valid postcode pattern mean the address is deliverable?
No. The pattern only checks the shape. Deliverability needs an address lookup or verification service against the postal authority's data.
What should happen when the user changes country after typing a postcode?
Keep the typed value, update the label and example, and re-run validation so a now-mismatched code is flagged. Never clear the user's input automatically.
Related Guides
- Phone and Address Validation — how postcode rules fit into the whole address form.
- Validating Autocompleted Address Fields — making autofill and lookups run these rules.
- HTML5 Pattern Attribute Regex Examples — when a static pattern attribute is enough.
- Conditional Field Validation on Selection — showing and hiding fields based on another choice.