Functional Weave
Code in Rust

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 it
  • is_uk_vat_number(220430231) → true the same registration unspaced and without the GB prefix
  • is_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.

pub fn is_uk_vat_number(value: &str) -> bool
valuestring9 or 12 digits, optionally prefixed GB and optionally spaced
returnsbool

Your code names it in one line, in the file that uses it

fune!(validation.uk-vat-number@^1);  // then call is_uk_vat_number(…)
impl/rust.rs · 107 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// 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: [i64; 7] = [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: i64 = 55;

/// Strip spaces, fold to upper case and drop any "GB" prefix.
///
/// The fold is ASCII-only, to match the TypeScript and Python siblings whose
/// built-in case folding is Unicode-aware and would turn "ß" into "SS".
/// Nothing in a VAT number is outside ASCII.
fn compact(value: &str) -> String {
    // Only the ASCII space is ignored. Tabs and non-breaking spaces usually
    // arrive from a bad paste, and silently accepting them hides that.
    let folded: String = value
        .chars()
        .filter(|ch| *ch != ' ')
        .map(|ch| ch.to_ascii_uppercase())
        .collect();
    match folded.strip_prefix("GB") {
        Some(rest) => rest.to_string(),
        None => folded,
    }
}

/// 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.
pub fn is_uk_vat_number(value: &str) -> bool {
    let normalised = compact(value);
    let chars: Vec<char> = normalised.chars().collect();

    // 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 chars.len() != 9 && chars.len() != 12 {
        return false;
    }

    let mut digits: Vec<i64> = Vec::with_capacity(chars.len());
    for ch in &chars {
        if *ch < '0' || *ch > '9' {
            return false;
        }
        digits.push(*ch as i64 - 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 digits[..9].iter().all(|d| *d == 0) {
        return false;
    }

    let mut total: i64 = 0;
    for i in 0..7 {
        total += digits[i] * WEIGHTS[i];
    }

    let check = digits[7] * 10 + digits[8];
    mod97_matches(total, check) || mod97_matches(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).rem_euclid(97)`
/// but this form is what a reviewer can hold against the published guidance.
fn mod97_matches(total: i64, check: i64) -> bool {
    let mut remainder = total;
    while remainder > 0 {
        remainder -= 97;
    }
    remainder.abs() == check
}

/// The canonical form, "GB999 9999 99", or `None` if the number does not check
/// out. Branch numbers keep their three-digit suffix.
pub fn format_uk_vat_number(value: &str) -> Option<String> {
    if !is_uk_vat_number(value) {
        return None;
    }
    let c = compact(value);
    let grouped = format!("GB{} {} {}", &c[0..3], &c[3..7], &c[7..9]);
    if c.len() == 12 {
        Some(format!("{} {}", grouped, &c[9..]))
    } else {
        Some(grouped)
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    // A non-string argument arrives here as an empty string, which is exactly
    // the answer TypeScript and Python give for a non-string: not a VAT number.
    Value::Bool(is_uk_vat_number(args[0].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add validation.uk-vat-number
Download for Rust validation.uk-vat-number-1.0.0-rust.fune · 10,774 bytes sha256 7cf81dca8d6418f95f44055af5c06874b8123e7e37843a697441f5fd6a77265f

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.uk-vat-number-1.0.0-rust.fune, or fetch it from a terminal with fune pull validation.uk-vat-number@1.0.0:rust.

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.

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

PathBytes
README.md2,688
impl/python.py3,627
impl/rust.rs4,095
impl/typescript.ts3,901
vectors.json2,320