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
isUkVatNumber(GB 220 4302 31)→ true a real published registration, spaced as HMRC prints itisUkVatNumber(220430231)→ true the same registration unspaced and without the GB prefixisUkVatNumber(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.
export function isUkVatNumber(value: string): boolean
| 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
import { isUkVatNumber } from "#fune/validation.uk-vat-number@^1";
/**
* 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.
*/
const 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.
*/
const MODULUS_9755_OFFSET = 55;
/**
* Uppercase ASCII only.
*
* `toUpperCase()` is locale- and 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.
*/
function asciiUpper(ch: string): string {
return ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 32) : ch;
}
/**
* 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.
*/
export function isUkVatNumber(value: string): boolean {
if (typeof value !== "string") return false;
// Only the ASCII space is ignored. Tabs and non-breaking spaces usually
// arrive from a bad paste, and silently accepting them hides that.
let compact = "";
for (const ch of value) {
if (ch === " ") continue;
compact += asciiUpper(ch);
}
if (compact.startsWith("GB")) compact = compact.slice(2);
// 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 (compact.length !== 9 && compact.length !== 12) return false;
const digits: number[] = [];
for (const ch of compact) {
if (ch < "0" || ch > "9") return false;
digits.push(ch.charCodeAt(0) - 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.
let allZero = true;
for (let i = 0; i < 9; i++) {
if (digits[i] !== 0) allZero = false;
}
if (allZero) return false;
let total = 0;
for (let i = 0; i < 7; i++) total += digits[i] * WEIGHTS[i];
const check = digits[7] * 10 + digits[8];
return mod97Matches(total, check) || mod97Matches(total + MODULUS_9755_OFFSET, check);
}
/**
* 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) mod 97` but
* this form is what a reviewer can hold against the published guidance.
*/
function mod97Matches(total: number, check: number): boolean {
let remainder = total;
while (remainder > 0) remainder -= 97;
return Math.abs(remainder) === check;
}
/**
* The canonical form, "GB999 9999 99", or null if the number does not check
* out. Branch numbers keep their three-digit suffix.
*/
export function formatUkVatNumber(value: string): string | null {
if (!isUkVatNumber(value)) return null;
let compact = "";
for (const ch of value) {
if (ch === " ") continue;
compact += asciiUpper(ch);
}
if (compact.startsWith("GB")) compact = compact.slice(2);
const grouped = `GB${compact.slice(0, 3)} ${compact.slice(3, 7)} ${compact.slice(7, 9)}`;
return compact.length === 12 ? `${grouped} ${compact.slice(9)}` : grouped;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./validation.uk-vat-number-1.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.uk-vat-number@1.0.0:typescript.
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 |