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.

Postcode shapes for common countries A table of countries with the local name of the postal code, its shape and an example. Local label Shape Example United Kingdom Postcode A9 9AA … AA9A 9AA SW1A 1AA United States ZIP code 99999 or 99999-9999 02134 Canada Postal code A9A 9A9 K1A 0B1 Netherlands Postcode 9999 AA 1012 AB Ireland Eircode A99 A9A9 D02 X285 Germany Postleitzahl 99999 10115
Shapes vary from five digits to seven alphanumeric characters with a space; the local label matters as much as the pattern.

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 check pipeline The raw postcode is upper-cased and trimmed, reformatted with the country's normaliser, tested against the country's pattern, and either accepted or rejected with a localised example. Raw input sw1a1aa Uppercase + trim SW1A1AA Country normaliser SW1A 1AA Pattern test GB rule Verdict valid, or error with example
Normalisation absorbs the harmless differences — case and spacing — so the pattern only has to reject genuine mistakes.

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.

Should the postcode field be shown and required? A decision tree deciding whether to hide, show as optional, or require the postcode field based on the selected country. Does the country use postal codes? no Hide and disable the field yes Do we have a rule for it? yes Required, validated with example no Optional, accepted as typed
The rule table answers all three questions: null hides the field, a rule makes it required, and no entry leaves it optional.

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.

← Back to Phone and Address Validation