# postal-code-formats **Postal code formats, validation regexes and 9,000 real sample codes for 31 countries.** Every regex is checked against every sample code in CI. Zero dependencies, no network calls. [![test](https://github.com/vinceblock99/postal-code-formats/actions/workflows/test.yml/badge.svg)](https://github.com/vinceblock99/postal-code-formats/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/postal-code-formats)](https://www.npmjs.com/package/postal-code-formats) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) --- ## Why another postal code list Most of the postal code regexes circulating today trace back to one of two places: a Wikipedia table scraped years ago, or Google's `libaddressinput` metadata, which is still published but no longer maintained as a standalone dataset. Both are *plausible*. Neither comes with anything that would tell you if a pattern had quietly gone wrong. This dataset takes one position: **a regex that has never been run against a real postal code is a guess.** So every pattern here is tested against real codes for that country — 9,000 of them — and the test runs in CI on every push. If a pattern and the reality disagree, the build goes red rather than the data going quietly stale. It also answers two questions the usual tables skip: - **Which countries have no postal code at all?** Hong Kong is in this dataset with `has_postal_code: false`, because a form that *requires* a postal code cannot be completed honestly there. Treating "no code" as "missing data" is a bug, not an edge case. - **Is the sample a whole code or half of one?** Canada, the UK, Ireland and the Netherlands publish their codes in two halves, and open data usually carries only the geographic half. Entries say which, in `sample_kind`, instead of pretending a Forward Sortation Area is a full postal code. ## Install ```bash npm install postal-code-formats ``` Or take the data directly — [`data/postal-formats.json`](data/postal-formats.json), [`data/postal-formats.csv`](data/postal-formats.csv), and one file of real codes per country under [`data/samples/`](data/samples). The data is plain and has no runtime; the npm package is a convenience, not a dependency you are stuck with. ## Use ```js import { validate, mask, localName, get } from "postal-code-formats"; validate("1012 AB", "NL"); // true validate("1012ab", "NL"); // true — separator and case are normalized validate("7030", "US"); // false — the leading-zero trap: 07030 is not 7030 validate("D4A 1B1", "CA"); // false — Canada Post never issues D, F, I, O, Q or U mask("DE"); // "NNNNN" — N is a digit, A is a letter localName("BR"); // "CEP" — label the field what the user calls it localName("IE"); // "Eircode" get("HK").has_postal_code; // false — Hong Kong has no postal codes validate("", "HK"); // true — so empty is the correct value validate("12345", "ZZ"); // undefined — NOT false. See below. ``` ### `undefined` is not `false` An unknown country returns `undefined`, deliberately. The most common way a validator hurts a real user is by rejecting a correct address for a country the developer never listed — so this library refuses to guess. Branch on it: ```js const ok = validate(code, country); if (ok === false) return "That does not look like a valid postal code."; // undefined → we have nothing to say about this country. Accept the input. ``` ### What "valid" means here Structure, and only structure. These patterns answer *"could the postal service read this?"* — not *"does this place exist?"*. A code can be perfectly well-formed and completely unassigned. Nothing here is a delivery-point lookup, and none of it should gate anything that matters on its own. ## Coverage | | Country | Local name | Format | Example | Regex | Real codes checked | |---|---|---|---|---|---|---:| | 🇦🇺 | Australia (`AU`) | Postcode | `NNNN` | `4110` | `^\d{4}$` | 127 | | 🇦🇹 | Austria (`AT`) | Postleitzahl (PLZ) | `NNNN` | `6029` | `^\d{4}$` | 150 | | 🇧🇪 | Belgium (`BE`) | Postcode / Code postal | `NNNN` | `6000` | `^\d{4}$` | 147 | | 🇧🇷 | Brazil (`BR`) | CEP | `NNNNN-NNN` | `33400-000` | `^\d{5}-?\d{3}$` | 50 | | 🇨🇦 | Canada (`CA`) | Postal code | `ANA NAN` | `B2V 1B1` | `^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$` | 149 *(prefix)* | | 🇩🇰 | Denmark (`DK`) | Postnummer | `NNNN` | `5290` | `^\d{4}$` | 90 | | 🇫🇮 | Finland (`FI`) | Postinumero | `NNNNN` | `45150` | `^\d{5}$` | 148 | | 🇫🇷 | France (`FR`) | Code postal | `NNNNN` | `51430` | `^\d{5}$` | 113 | | 🇩🇪 | Germany (`DE`) | Postleitzahl (PLZ) | `NNNNN` | `42275` | `^\d{5}$` | 150 | | 🇭🇰 | Hong Kong (`HK`) | — | *no postal code system* | — | — | — | | 🇭🇺 | Hungary (`HU`) | Irányítószám | `NNNN` | `6727` | `^\d{4}$` | 126 | | 🇮🇳 | India (`IN`) | PIN code | `NNNNNN` | `400018` | `^\d{6}$` | 148 | | 🇮🇩 | Indonesia (`ID`) | Kode pos | `NNNNN` | `30253` | `^\d{5}$` | 145 | | 🇮🇪 | Ireland (`IE`) | Eircode | `ANN AAAA` | `D01 F4E2` | `^[AC-FHKNPRTV-Y]\d[0-9W] ?[0-9AC-FHKNPRTV-Y]{4}$` | 12 *(prefix)* | | 🇮🇹 | Italy (`IT`) | CAP | `NNNNN` | `37130` | `^\d{5}$` | 150 | | 🇯🇵 | Japan (`JP`) | 郵便番号 (yūbin bangō) | `NNN-NNNN` | `100-0001` | `^\d{3}-?\d{4}$` | — *(none)* | | 🇲🇽 | Mexico (`MX`) | Código postal | `NNNNN` | `52985` | `^\d{5}$` | 134 | | 🇳🇱 | Netherlands (`NL`) | Postcode | `NNNN AA` | `1011 AB` | `^\d{4} ?[A-Z]{2}$` | 150 *(prefix)* | | 🇳🇴 | Norway (`NO`) | Postnummer | `NNNN` | `4634` | `^\d{4}$` | 150 | | 🇵🇱 | Poland (`PL`) | Kod pocztowy | `NN-NNN` | `44-114` | `^\d{2}-?\d{3}$` | 150 | | 🇵🇹 | Portugal (`PT`) | Código postal | `NNNN-NNN` | `3810-066` | `^\d{4}-?\d{3}$` | 150 | | 🇷🇴 | Romania (`RO`) | Cod poștal | `NNNNNN` | `410320` | `^\d{6}$` | 150 | | 🇷🇺 | Russia (`RU`) | Почтовый индекс | `NNNNNN` | `425411` | `^\d{6}$` | 145 | | 🇰🇷 | South Korea (`KR`) | 우편번호 | `NNNNN` | `37727` | `^\d{5}$` | 150 | | 🇪🇸 | Spain (`ES`) | Código postal | `NNNNN` | `33205` | `^\d{5}$` | 150 | | 🇸🇪 | Sweden (`SE`) | Postnummer | `NNN NN` | `582 56` | `^\d{3} ?\d{2}$` | 150 | | 🇨🇭 | Switzerland (`CH`) | Postleitzahl / NPA | `NNNN` | `4002` | `^\d{4}$` | 130 | | 🇹🇷 | Turkey (`TR`) | Posta kodu | `NNNNN` | `27410` | `^\d{5}$` | 120 | | 🇺🇦 | Ukraine (`UA`) | Поштовий індекс | `NNNNN` | `49112` | `^\d{5}$` | 143 | | 🇬🇧 | United Kingdom (`GB`) | Postcode | `AN NAA / ANN NAA / AAN NAA / AANN NAA / ANA NAA / AANA NAA` | `B1 1BB` | `^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$` | 131 *(prefix)* | | 🇺🇸 | United States (`US`) | ZIP Code | `NNNNN or NNNNN-NNNN` | `55810` | `^\d{5}(-\d{4})?$` | 5292 | `sample_kind` is `full` unless marked. *(prefix)* means the sample file holds the published geographic half of the code — a Canadian FSA, a UK outward code, an Eircode routing key, a Dutch four-digit block — and the `example` column shows one of those prefixes completed into a full code. *(none)* means no sample file: Japan's open postal data is romanised, and this project's Japanese addresses are native script, so the two were never joined honestly enough to publish. ## The data Each entry: ```json { "iso2": "IE", "country": "Ireland", "local_name": "Eircode", "has_postal_code": true, "mask": "ANN AAAA", "regex": "^[AC-FHKNPRTV-Y]\\d[0-9W] ?[0-9AC-FHKNPRTV-Y]{4}$", "example": "D01 F4E2", "samples": 12, "sample_kind": "prefix", "observed_masks": ["ANN"], "note": "Introduced in 2015. The three-character routing key is geographic; the four-character identifier resolves to ONE delivery point, so two neighbouring houses have different Eircodes. The samples are routing keys." } ``` `observed_masks` is the shape of the sample codes as measured, not as asserted — it is there so you can see the data disagreeing with the `mask` field if it ever does. ## Testing your own forms against this The sample files are the useful part for test suites: real codes, one per line, [`data/samples/.txt`](data/samples). They make good fixtures precisely because they are not uniform — Sweden's carry a space, Poland's a hyphen, Portugal's four digits then three. ```js import { readFileSync } from "node:fs"; const codes = readFileSync("node_modules/postal-code-formats/data/samples/SE.txt", "utf8") .split("\n").filter(Boolean); // → ["100 04", "103 16", "105 47", ...] ``` ## Provenance and limits The sample codes come from [GeoNames](https://download.geonames.org/export/zip/) postal data (CC BY 4.0), joined to its population-ranked city list, as used in production by [Fakenamely](https://fakenamely.com). Patterns and the per-country notes were written against the national postal authorities' own published standards (USPS Publication 28, Canada Post, Royal Mail, An Post, PostNL and others). Honest limits, so you can decide whether this is enough for your case: - **31 countries, not ~250.** These are the ones with sample data good enough to test against. A short list that is verified beats a long one that is not — but if you need full ISO coverage, this is not that. - **A snapshot, not a feed.** Postal systems change: Germany renumbered in 1993, Japan in 1998, Korea in 2015, Ireland went from no codes at all to per-door codes. This data is dated in `data/postal-formats.json` and will drift. - **Samples are a slice.** ~150 codes per country (5,292 for the US), not a directory. They exist to test the patterns, not to enumerate a country. - Not affiliated with any postal authority. ## Contributing Corrections are very welcome, especially from people who live with a postal system day to day. A pull request that changes a pattern should come with sample codes it accepts that the old one rejected (or vice versa) — `npm test` will hold you to it. Adding a country means adding `data/samples/.txt` and one entry in `data/postal-formats.json`; the test suite checks the rest. ## License MIT for the code and the compiled dataset. The underlying GeoNames postal data is [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution to GeoNames carries over to anything derived from `data/samples/`.