validation.uk-vat-number
Check a UK VAT registration number against HMRC's mod-97 checksum, including the post-2010 9755 variant.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 22 tests, run in TypeScript, Python and Rust.
What it does
THE CHECKSUM IS NOT A REGISTRATION CHECK. A number that passes is arithmetically well formed; it does not mean the trader is registered, is still registered, or is the trader named on the invoice. Only HMRC's VAT number checker (or the EU VIES service for cross-border supplies) can tell you that, and for a zero-rated intra-community supply or a reverse charge you are expected to have asked and to have kept the evidence. Use this to reject a typo before it reaches the return, not as the evidence itself.
FORMAT: nine digits for a standard registration, or twelve for a branch trader, where the final three identify the branch and take no part in the checksum. An optional "GB" prefix is accepted in any case. ASCII spaces are ignored anywhere, including leading and trailing, because HMRC prints the number as "GB 999 9999 99" and people paste it that way. Nothing else is ignored: a tab, a hyphen or a non-breaking space makes the value invalid rather than being skipped, because those almost always mean a bad paste that the caller should see.
For example
is_uk_vat_number(GB 220 4302 31)→ true a real published registration, spaced as HMRC prints itis_uk_vat_number(220430231)→ true the same registration unspaced and without the GB prefixis_uk_vat_number(gb220430231)→ true a lower case prefix is folded
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 is_uk_vat_number(value: str) -> bool
| value | string | 9 or 12 digits, optionally prefixed GB and optionally spaced |
| returns | bool |
Your code names it in one line, in the file that uses it
from fune.validation.uk_vat_number import is_uk_vat_number # validation.uk-vat-number@^1
from typing import List, Optional
#: The weights HMRC applies to the first seven digits, most significant first.
#: They are 8 down to 2, which is why the last two digits (the check pair) get
#: no weight of their own.
_WEIGHTS = (8, 7, 6, 5, 4, 3, 2)
#: The offset HMRC added for registrations issued from roughly 2010 onwards,
#: commonly called the "9755" variant after the constant in their published
#: worked example. A number satisfies exactly one of the two checks, never
#: both, because 55 is not a multiple of 97.
_MODULUS_9755_OFFSET = 55
def _ascii_upper(ch: str) -> str:
"""Uppercase ASCII only.
``str.upper()`` is Unicode-aware: it turns "ß" into "SS" and would make
this function disagree with the Rust sibling, which has no such machinery.
Nothing in a VAT number is outside ASCII, so restricting the fold keeps
all three languages honest.
"""
return chr(ord(ch) - 32) if "a" <= ch <= "z" else ch
def _compact(value: str) -> str:
# Only the ASCII space is ignored. Tabs and non-breaking spaces usually
# arrive from a bad paste, and silently accepting them hides that.
out = "".join(_ascii_upper(ch) for ch in value if ch != " ")
return out[2:] if out.startswith("GB") else out
def is_uk_vat_number(value: str) -> bool:
"""Is this a valid UK VAT registration number?
Nine digits, or twelve for a branch trader, optionally prefixed "GB" and
optionally spaced. The last two of the first nine are a mod-97 check pair.
A number that passes is arithmetically well formed. It is not proof that
the trader is registered, still registered, or registered for the goods on
the invoice: only HMRC's VAT number checker can tell you that, and for a
reverse-charge or zero-rated supply you are expected to have asked.
"""
if not isinstance(value, str):
return False
compact = _compact(value)
# Nine digits is a standard registration; twelve is a branch trader, where
# the final three identify the branch and take no part in the checksum.
if len(compact) != 9 and len(compact) != 12:
return False
digits: List[int] = []
for ch in compact:
if ch < "0" or ch > "9":
return False
digits.append(ord(ch) - 48)
# 000000000 satisfies the arithmetic and has never been issued to anyone.
# Rejecting it costs nothing and stops an all-zero placeholder field from
# reading as a valid registration.
if all(d == 0 for d in digits[:9]):
return False
total = sum(digits[i] * _WEIGHTS[i] for i in range(7))
check = digits[7] * 10 + digits[8]
return _mod97_matches(total, check) or _mod97_matches(total + _MODULUS_9755_OFFSET, check)
def _mod97_matches(total: int, check: int) -> bool:
"""HMRC's "97 check", written the way HMRC writes it: subtract 97 from the
weighted total until the result is zero or negative, then compare the
magnitude with the check pair. That is the same as ``(-total) % 97`` but
this form is what a reviewer can hold against the published guidance.
"""
remainder = total
while remainder > 0:
remainder -= 97
return abs(remainder) == check
def format_uk_vat_number(value: str) -> Optional[str]:
"""The canonical form, "GB999 9999 99", or None if the number does not
check out. Branch numbers keep their three-digit suffix.
"""
if not is_uk_vat_number(value):
return None
compact = _compact(value)
grouped = "GB%s %s %s" % (compact[0:3], compact[3:7], compact[7:9])
return grouped + " " + compact[9:] if len(compact) == 12 else groupedInstall
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-vat-number
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.uk-vat-number-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.uk-vat-number@1.0.0:python.
The whole function, every language, is one file too: validation.uk-vat-number-1.0.0.fune, 18,619 bytes, sha256 d1a950461b5bbdba2866ab53c031bf097fcc1483dfc4827b44e4930675b2d4da. 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-vat-number
after — your function gets the result and the arguments, and returns the final result.
# fune: after validation.uk-vat-number
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-vat-number --steps.
# fune: step validation.uk-vat-number 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 | |
|---|---|---|---|
| a real published registration, spaced as HMRC prints it | GB 220 4302 31 | → | true |
| the same registration unspaced and without the GB prefix | 220430231 | → | true |
| a lower case prefix is folded | gb220430231 | → | true |
| another real published registration | GB 980 7806 84 | → | true |
| and a third, to pin the weighting rather than one lucky number | 232128892 | → | true |
| a branch trader carries three extra digits outside the checksum | GB220430231001 | → | true |
| a number that only passes under the post-2010 9755 variant | 220430273 | → | true |
| another 9755-only number, to pin the offset not the number | 123456727 | → | true |
| the same digits with a check pair for the pre-2010 rule | 123456782 | → | true |
| right length, check pair off by one | 220430232 | → | false |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the familiar dummy number fails both checksums | GB123456789 | → | false |
| the empty string is not a registration | → | false | |
| eight digits is too short | 22043023 | → | false |
| ten digits is neither nine nor twelve | 2204302311 | → | false |
| letters where the digits belong | GB2204302AB | → | false |
| a valid UK number is still not an Irish one | IE220430231 | → | false |
| all zeros satisfies the arithmetic and is refused anyway | 000000000 | → | false |
| spaces anywhere are ignored, including leading and trailing | GB 2 2 0 4 3 0 2 3 1 | → | true |
| a non-breaking space is not a space and fails | GB 220430231 | → | false |
| the GD government department format is out of scope | GBGD001 | → | false |
| the HA health authority format is out of scope | GBHA599 | → | false |
| a non-string argument is not a registration | 220,430,231 | → | false |
More from the author
THE 97 CHECK: multiply the first seven digits by the weights 8, 7, 6, 5, 4, 3, 2 and sum them. Subtract 97 from the total repeatedly until the result is zero or negative. The absolute value of that result must equal the last two digits, read as a two-digit number. This is HMRC's own formulation, and the implementations are written in that shape rather than as (-total) mod 97 so that a reviewer can hold them against the published guidance.
THE 9755 VARIANT: HMRC introduced a second series for registrations issued from around 2010. It is the same calculation with 55 added to the weighted total before the subtraction loop. A number is valid if it satisfies either rule, and it can never satisfy both, because 55 is not a multiple of 97. Checking only the older rule silently rejects about half of the registrations issued in the last fifteen years, which is the most common bug in home-grown VAT validators.
DELIBERATE EXCLUSIONS: the GD (government department, GBGD000-GBGD499) and HA (health authority, GBHA500-GBHA999) formats are rejected. They carry no checksum at all, so accepting them would mean accepting any three digits behind those letters, and a caller who needs them should test for the prefix explicitly rather than have this function wave them through.
All-zero numbers are rejected even though 000000000 satisfies the arithmetic. No such registration has ever been issued, and an empty placeholder field reading as valid is a worse failure than a false negative here.
Validators answer rather than throw: an unparseable value is not an exceptional condition, it is the answer "no".
Files
| Path | Bytes |
|---|---|
| README.md | 2,688 |
| impl/python.py | 3,627 |
| impl/rust.rs | 4,095 |
| impl/typescript.ts | 3,901 |
| vectors.json | 2,320 |