validation.uk-postcode
Validate a UK postcode and normalise it to canonical upper case with one space before the inward code.
2.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 26 tests, run in TypeScript, Python and Rust.
What it does
Returns a result object rather than a boolean because the caller almost always wants both answers at once: whether to accept the value, and what to store. Storing what the user typed means "sw1a1aa" and "SW1A 1AA" are two different rows for one address; storing `normalised` means they are one. Splitting outward from inward is the difference between "can we deliver here" and "which delivery office is this", and callers that only want the yes or no have isUkPostcode / is_uk_postcode.
Malformed input returns valid=false with nulls rather than throwing, so `result.normalised ?? typed` is always safe and a bad address never becomes an exception in the middle of a form submission.
For example
validate_uk_postcode(EC1A 1BB)→ valid true, normalised EC1A 1BB, outward EC1A, inward 1BB AA9A 9AA, the London EC1 formvalidate_uk_postcode(W1A 0AX)→ valid true, normalised W1A 0AX, outward W1A, inward 0AX A9A 9AA, the single-letter West End formvalidate_uk_postcode(M1 1AE)→ valid true, normalised M1 1AE, outward M1, inward 1AE A9 9AA, the shortest postcode there is
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def validate_uk_postcode(value: str) -> UkPostcode
| value | string | A postcode in any case, with or without the space |
| returns | UkPostcode |
The type it declares, generated into your project
@dataclass(frozen=True)
class UkPostcode:
"""The three strings are null when valid is false."""
valid: bool
normalised: Optional[str]
outward: Optional[str]
inward: Optional[str]
Your code names it in one line, in the file that uses it
from fune.validation.uk_postcode import validate_uk_postcode # validation.uk-postcode@^2
from typing import List
from .validation_uk_postcode_types import UkPostcode
#: Letters excluded from the two final characters of the inward code. They are
#: the ones most easily misread in handwriting and on a sorting machine, so
#: Royal Mail never issued them there.
_INWARD_EXCLUDED = "CIKMOV"
#: Q, V and X never appear as the first letter of a postcode area.
_AREA_FIRST_EXCLUDED = "QVX"
#: I, J and Z never appear as the second letter of a two-letter area.
_AREA_SECOND_EXCLUDED = "IJZ"
#: The only letters used in the third position of the A9A district form.
_THIRD_POSITION_ALLOWED = "ABCDEFGHJKPSTUW"
#: The only letters used in the fourth position of the AA9A district form.
_FOURTH_POSITION_ALLOWED = "ABEHMNPRVWXY"
#: The one postcode that obeys no rule: Girobank's, kept in service since 1968
#: and still valid on an address label.
_GIROBANK = "GIR0AA"
_INVALID = UkPostcode(valid=False, normalised=None, outward=None, inward=None)
def _is_digit(ch: str) -> bool:
return "0" <= ch <= "9"
def _is_letter(ch: str) -> bool:
return "A" <= ch <= "Z"
def validate_uk_postcode(value: str) -> UkPostcode:
"""Parse and normalise a UK postcode.
Returns a result rather than raising, and rather than returning a bare
boolean: the caller nearly always wants the canonical form to store, and
splitting outward from inward is the difference between "can we deliver
here" and "which delivery office is this". A malformed value comes back as
valid=False with Nones, so ``result.normalised or original`` is safe.
Normalisation is upper case with exactly one space before the final three
characters: "sw1a1aa" and " SW1A 1AA " both become "SW1A 1AA". That is
the form Royal Mail prints and the form two records should be compared in.
"""
if not isinstance(value, str):
return _INVALID
# Only the ASCII space is stripped, and it is stripped everywhere: people
# type the space in the wrong place far more often than they omit it, so
# position carries no information worth preserving.
chars: List[str] = []
for ch in value:
if ch == " ":
continue
chars.append(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch)
code = "".join(chars)
# Five is "M1 1AE", seven is "DN55 1PT"; nothing shorter or longer exists.
if len(code) < 5 or len(code) > 7:
return _INVALID
if code == _GIROBANK:
return UkPostcode(valid=True, normalised="GIR 0AA", outward="GIR", inward="0AA")
inward = code[-3:]
outward = code[:-3]
if not _is_inward(inward) or not _is_outward(outward):
return _INVALID
return UkPostcode(valid=True, normalised=outward + " " + inward, outward=outward, inward=inward)
def _is_inward(inward: str) -> bool:
"""The inward code is always a digit followed by two letters."""
if not _is_digit(inward[0]):
return False
for ch in inward[1:3]:
if not _is_letter(ch) or ch in _INWARD_EXCLUDED:
return False
return True
def _is_outward(outward: str) -> bool:
"""The outward code is one of six shapes: A9, A99, A9A, AA9, AA99, AA9A.
The letter restrictions below are not cosmetic - they are what stops a
plausible looking string such as "QW1A" from being accepted.
"""
if not _is_letter(outward[0]) or outward[0] in _AREA_FIRST_EXCLUDED:
return False
if len(outward) == 2:
# A9
return _is_digit(outward[1])
if len(outward) == 3:
if _is_letter(outward[1]):
# AA9
return outward[1] not in _AREA_SECOND_EXCLUDED and _is_digit(outward[2])
# A99 or A9A
if not _is_digit(outward[1]):
return False
return _is_digit(outward[2]) or outward[2] in _THIRD_POSITION_ALLOWED
if len(outward) == 4:
# AA99 or AA9A
if not _is_letter(outward[1]) or outward[1] in _AREA_SECOND_EXCLUDED:
return False
if not _is_digit(outward[2]):
return False
return _is_digit(outward[3]) or outward[3] in _FOURTH_POSITION_ALLOWED
return False
def is_uk_postcode(value: str) -> bool:
"""Shorthand for callers that only need the yes or no."""
return validate_uk_postcode(value).validInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add validation.uk-postcode
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.uk-postcode-2.0.0-python.fune, or fetch it from a terminal with fune pull validation.uk-postcode@2.0.0:python.
The whole function, every language, is one file too: validation.uk-postcode-2.0.0.fune, 24,508 bytes, sha256 647091250dc23f8ef1175a6802294d5d573a21c5b6adf039649845ee87df8634. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before validation.uk-postcode
after — your function gets the result and the arguments, and returns the final result.
# fune: after validation.uk-postcode
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.uk-postcode --steps.
# fune: step validation.uk-postcode after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| AA9A 9AA, the London EC1 form | EC1A 1BB | → | valid true, normalised EC1A 1BB, outward EC1A, inward 1BB |
| A9A 9AA, the single-letter West End form | W1A 0AX | → | valid true, normalised W1A 0AX, outward W1A, inward 0AX |
| A9 9AA, the shortest postcode there is | M1 1AE | → | valid true, normalised M1 1AE, outward M1, inward 1AE |
| A99 9AA | B33 8TH | → | valid true, normalised B33 8TH, outward B33, inward 8TH |
| AA9 9AA | CR2 6XH | → | valid true, normalised CR2 6XH, outward CR2, inward 6XH |
| AA99 9AA, the longest postcode there is | DN55 1PT | → | valid true, normalised DN55 1PT, outward DN55, inward 1PT |
| lower case and no space at all is normalised | sw1a1aa | → | valid true, normalised SW1A 1AA, outward SW1A, inward 1AA |
| stray spaces anywhere are normalised away | sw1a 1aa | → | valid true, normalised SW1A 1AA, outward SW1A, inward 1AA |
| the Girobank special case, which fits none of the six shapes | GIR 0AA | → | valid true, normalised GIR 0AA, outward GIR, inward 0AA |
| the Girobank special case, unspaced and lower case | gir0aa | → | valid true, normalised GIR 0AA, outward GIR, inward 0AA |
Show the other 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a Scottish postcode, to pin the area rules beyond London | EH12 9DN | → | valid true, normalised EH12 9DN, outward EH12, inward 9DN |
| a Northern Irish postcode | BT1 5GS | → | valid true, normalised BT1 5GS, outward BT1, inward 5GS |
| the empty string is not a postcode | → | valid false, normalised —, outward —, inward — | |
| one character too long | SW1A 1AAA | → | valid false, normalised —, outward —, inward — |
| one character too short | M1 1A | → | valid false, normalised —, outward —, inward — |
| Q never starts a postcode area | QW1A 1AA | → | valid false, normalised —, outward —, inward — |
| I is never the second letter of an area | SI1A 1AA | → | valid false, normalised —, outward —, inward — |
| I is not one of the letters used in the AA9A fourth position | SW1I 1AA | → | valid false, normalised —, outward —, inward — |
| I is not one of the letters used in the A9A third position | W1I 0AX | → | valid false, normalised —, outward —, inward — |
| C is excluded from the first letter of the inward code | SW1A 1CA | → | valid false, normalised —, outward —, inward — |
| C is excluded from the second letter of the inward code | SW1A 1AC | → | valid false, normalised —, outward —, inward — |
| the inward code must end in two letters, not a digit | SW1A 1A1 | → | valid false, normalised —, outward —, inward — |
| a postcode cannot start with a digit | 1W1A 1AA | → | valid false, normalised —, outward —, inward — |
| all digits, the right length and still not a postcode | 12345 | → | valid false, normalised —, outward —, inward — |
| a hyphen is not a separator this accepts | SW1A-1AA | → | valid false, normalised —, outward —, inward — |
| a non-string argument is not a postcode | 1 | → | valid false, normalised —, outward —, inward — |
More from the author
FORMAT: the outward code is one of six shapes - A9, A99, A9A, AA9, AA99, AA9A - and the inward code is always 9AA. The letter restrictions are real rules, not decoration, and they are what rejects plausible-looking strings: Q, V and X never start an area; I, J and Z are never the second letter of a two-letter area; the third position of the A9A form is restricted to ABCDEFGHJKPSTUW; the fourth position of the AA9A form to ABEHMNPRVWXY; and the final two letters exclude C, I, K, M, O and V, which are the ones most easily misread by a person or a sorting machine.
GIR 0AA is accepted as a special case. It was issued to Girobank in 1968, fits none of the six shapes, and is still valid on an address label; a validator that rejects it is wrong in a way that is very hard for the affected user to argue with.
NORMALISATION: every ASCII space is stripped and exactly one is reinserted before the final three characters. People put the space in the wrong place far more often than they leave it out, so its original position carries no information worth preserving. Only the ASCII space is stripped - a hyphen or a non-breaking space makes the value invalid rather than being cleaned up, because those mean the value came from somewhere unexpected.
A WELL-FORMED POSTCODE IS NOT A REAL ADDRESS. This checks the shape, not existence: "AA1 1AA" passes every rule above and has never been issued. Only the Royal Mail Postcode Address File (PAF), or an address lookup service built on it, can tell you a postcode exists and what is at it. Use this to catch typing errors before the lookup, not instead of it.
OUT OF SCOPE: BFPO numbers for British Forces addresses, the overseas territory codes (Gibraltar's GX11 1AA, the Falklands' FIQQ 1ZZ, Ascension's ASCN 1ZZ and the rest) and Crown dependency codes are not special-cased; the ones that happen to fit the six shapes pass, and the ones that do not are rejected. If you accept forces or territory mail, test for those prefixes before calling this.
2.0.0 renames the function from `ukPostcode` to `validateUkPostcode` (`validate_uk_postcode` in Python and Rust), so every validator that returns a result record is `validateX` and every one that returns a bool is `isX`. Nothing else changed; 1.0.0 stays published under the old name.
Files
| Path | Bytes |
|---|---|
| README.md | 3,000 |
| impl/python.py | 4,267 |
| impl/rust.rs | 5,621 |
| impl/typescript.ts | 4,317 |
| vectors.json | 4,285 |