Functional Weave
Code in Python

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 form
  • validate_uk_postcode(W1A 0AX) → valid true, normalised W1A 0AX, outward W1A, inward 0AX A9A 9AA, the single-letter West End form
  • validate_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
valuestringA postcode in any case, with or without the space
returnsUkPostcode

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
impl/python.py · 126 lines · open · raw
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).valid

Install

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
Download for Python validation.uk-postcode-2.0.0-python.fune · 14,150 bytes sha256 3828ccf77627a9b2909e1fa2d7febd04451c253aa698a90b9521d6168645418e

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md3,000
impl/python.py4,267
impl/rust.rs5,621
impl/typescript.ts4,317
vectors.json4,285