validation.uk-sort-code-account
Check a UK sort code and account number with the Vocalink/Pay.UK modulus rules, against a table you supply.
3.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 64 tests, run in TypeScript, Python and Rust.
What it does
Checks a UK sort code and account number against the modulus rules banks publish through Vocalink (for Pay.UK), the same check Bacs recommends before a Direct Credit or Direct Debit instruction is submitted.
A PASS IS NOT AN ACCOUNT. The specification is explicit: a valid result means the account number is a possible account at that sorting code, not that it is open, in use or belongs to the payee. Use Confirmation of Payee for that. Use this to catch a mistyped digit before it becomes a returned payment.
For example
validate_uk_sort_code_account(115000, 12345672, rows ×1, substitutions )→ valid true, sort code 115000, account number 12345672, checked true, reason — MOD10 passes: 7+2+9+28+5+18+49+2 = 120validate_uk_sort_code_account(115000, 12345678, rows ×1, substitutions )→ valid false, sort code —, account number —, checked true, reason bad-check-digit MOD10 fails: 7+2+9+28+5+18+49+8 = 126validate_uk_sort_code_account(120000, 12345679, rows ×1, substitutions )→ valid true, sort code 120000, account number 12345679, checked true, reason — MOD11 passes: 8+14+18+20+20+18+14+9 = 121 = 11 x 11
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_sort_code_account(sort_code: str, account_number: str, table: UkModulusTable) -> UkSortCodeAccount
| sort_code | string | six digits, optionally with spaces or hyphens: "08-99-99" |
| account_number | string | six to eight digits, optionally spaced; six and seven are zero-padded |
| table | UkModulusTable | Vocalink's VALACDOS.txt and SCSUBTAB.txt, parsed by parseUkModulusTable |
| returns | UkSortCodeAccount | throws only for a malformed table, never for a bad sort code or account number |
The type it declares, generated into your project
@dataclass(frozen=True)
class UkSortCodeAccount:
"""The two numbers are null when valid is false."""
valid: bool
#: six digits, no separators
sort_code: Optional[str]
#: eight digits, after zero-padding
account_number: Optional[str]
#: true when a modulus rule decided the answer; false when none applies
checked: bool
#: null when valid; bad-sort-code, bad-account-number, non-standard-account or bad-check-digit
reason: Optional[str]
Your code names it in one line, in the file that uses it
from fune.validation.uk_sort_code_account import validate_uk_sort_code_account # validation.uk-sort-code-account@^3
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import re
from typing import List, Optional
from .validation_uk_modulus_table import UkModulusRow, UkModulusTable ← from validation.uk-modulus-table ^1.0.0 · built alongside by fune
from .validation_uk_sort_code_account_types import UkSortCodeAccount
#: Exception 2's replacement weights when a is not 0: one set when g is not 9,
#: another when it is. Both are part of the algorithm, in section 2 of
#: Vocalink's "Validating account numbers", not of the weight table.
_EXCEPTION_2_WEIGHTS = (0, 0, 1, 2, 5, 3, 6, 4, 8, 7, 10, 9, 3, 1)
_EXCEPTION_2_WEIGHTS_G9 = (0, 0, 0, 0, 0, 0, 0, 0, 8, 7, 10, 9, 3, 1)
#: Exception 8 checks against this sorting code; exception 9 against Lloyds'
#: euro sorting code.
_EXCEPTION_8_SORT_CODE = "090126"
_EXCEPTION_9_SORT_CODE = "309634"
_SORT_CODE = re.compile(r"[0-9]{6}")
def _is_sort_code(value: object) -> bool:
return isinstance(value, str) and _SORT_CODE.fullmatch(value) is not None
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _check_table(table: UkModulusTable) -> None:
"""A table built by hand, or edited after parsing, can hold what the
parser refuses. Refuse it here too, loudly: a bad row would otherwise
index past the weights or quietly check nothing."""
for i, row in enumerate(table.rows):
where = "modulus table row %d" % (i + 1)
if not _is_sort_code(row.start) or not _is_sort_code(row.end):
raise ValueError("%s: start and end must be six-digit sort codes" % where)
if row.algorithm not in ("MOD10", "MOD11", "DBLAL"):
raise ValueError('%s: unknown algorithm "%s"; expected MOD10, MOD11 or DBLAL' % (where, row.algorithm))
if len(row.weights) != 14:
raise ValueError("%s: expected 14 weights, found %d" % (where, len(row.weights)))
if not all(_is_int(w) for w in row.weights):
raise ValueError("%s: weights must be whole numbers" % where)
if row.algorithm == "DBLAL" and any(w < 0 for w in row.weights):
raise ValueError("%s: a DBLAL row cannot have a negative weight" % where)
if row.exception is not None and (not _is_int(row.exception) or not 1 <= row.exception <= 14):
raise ValueError("%s: exception must be from 1 to 14, received %s" % (where, row.exception))
for i, s in enumerate(table.substitutions):
if not _is_sort_code(s.original) or not _is_sort_code(s.substitute):
raise ValueError(
"modulus table substitution %d: original and substitute must be six-digit sort codes" % (i + 1)
)
def _digits_only(value: str) -> Optional[str]:
"""Strip ASCII spaces and hyphens; None if anything else is not a digit."""
out: List[str] = []
for ch in value:
if ch == " " or ch == "-":
continue
if ch < "0" or ch > "9":
return None
out.append(ch)
return "".join(out)
def _invalid(reason: str, checked: bool) -> UkSortCodeAccount:
return UkSortCodeAccount(valid=False, sort_code=None, account_number=None, checked=checked, reason=reason)
def _run_check(row: UkModulusRow, table: UkModulusTable, sort_code: str, account: str) -> bool:
"""One modulus check from one row of the weight table, with its exception."""
weights = list(row.weights)
ex = row.exception
a = ord(account[0]) - 48
g = ord(account[6]) - 48
h = ord(account[7]) - 48
# Substitutions are "for check purposes only": they change the digits that
# are weighted, never which rows of the table apply.
if ex == 5:
for s in table.substitutions:
if s.original == sort_code:
sort_code = s.substitute
break
elif ex == 8:
sort_code = _EXCEPTION_8_SORT_CODE
elif ex == 9:
sort_code = _EXCEPTION_9_SORT_CODE
if ex == 2 and a != 0:
weights = list(_EXCEPTION_2_WEIGHTS_G9 if g == 9 else _EXCEPTION_2_WEIGHTS)
if ex == 7 and g == 9:
weights = [0] * 8 + weights[8:]
if ex == 10 and account[0:2] in ("09", "99") and g == 9:
weights = [0] * 8 + weights[8:]
number = sort_code + account
total = 0
for i in range(14):
product = (ord(number[i]) - 48) * weights[i]
# Double alternate adds the individual digits of each product, so 14
# counts as 1 + 4; the standard checks add the products themselves.
# _check_table keeps DBLAL weights non-negative, so the product is too.
total += product // 10 + product % 10 if row.algorithm == "DBLAL" else product
if ex == 1:
total += 27
# A MOD11 row may have a negative weight, so the total may be negative;
# Python's % already gives 0 to 10, as the other languages are made to.
if ex == 4:
return total % 11 == g * 10 + h
if ex == 5:
if row.algorithm == "DBLAL":
remainder = total % 10
return h == 0 if remainder == 0 else 10 - remainder == h
remainder = total % 11
if remainder == 0:
return g == 0
if remainder == 1:
return False
return 11 - remainder == g
modulus = 11 if row.algorithm == "MOD11" else 10
return total % modulus == 0
def validate_uk_sort_code_account(sort_code: str, account_number: str, table: UkModulusTable) -> UkSortCodeAccount:
"""Modulus-check a UK sort code and account number against a weight table
the caller supplies (Vocalink's VALACDOS.txt and SCSUBTAB.txt, parsed by
parse_uk_modulus_table).
A pass means the pair is a possible account at that sorting code, not that
it exists or belongs to anyone in particular: that is Confirmation of Payee's
job. A sort code no rule covers is presumed valid, as the specification
says, and reported with checked=False so the caller can tell.
"""
_check_table(table)
sc = _digits_only(sort_code) if isinstance(sort_code, str) else None
if sc is None or len(sc) != 6:
return _invalid("bad-sort-code", False)
account = _digits_only(account_number) if isinstance(account_number, str) else None
if account is None or len(account) < 6 or len(account) > 10:
return _invalid("bad-account-number", False)
# Nine and ten digit numbers are standardised differently bank by bank
# (NatWest keeps the last eight, Co-operative the first eight, Santander
# moves a digit into the sort code), and nothing here knows the bank.
if len(account) > 8:
return _invalid("non-standard-account", False)
account = account.rjust(8, "0")
rules = [row for row in table.rows if row.start <= sc <= row.end]
if not rules:
return UkSortCodeAccount(valid=True, sort_code=sc, account_number=account, checked=False, reason=None)
a = ord(account[0]) - 48
g = ord(account[6]) - 48
h = ord(account[7]) - 48
# Exception 6: foreign currency accounts at these sorting codes follow no
# published rule, so they cannot be checked either way.
if rules[0].exception == 6 and 4 <= a <= 8 and g == h:
return UkSortCodeAccount(valid=True, sort_code=sc, account_number=account, checked=False, reason=None)
first = rules[0]
passed = _run_check(first, table, sc, account)
if first.exception == 14 and not passed:
# Coutts: an eighth digit of 0, 1 or 9 is dropped and a zero put in
# front, then the same modulus 11 check is run again.
if h in (0, 1, 9):
passed = _run_check(first, table, sc, "0" + account[:7])
elif len(rules) > 1:
second = rules[1]
if first.exception in (2, 10, 12):
# Pairs 2 & 9, 10 & 11 and 12 & 13: either check passing is enough.
passed = passed or _run_check(second, table, sc, account)
elif passed and not (second.exception == 3 and account[2] in ("6", "9")):
# Every other pair needs both; exception 3 skips the second when c
# is 6 or 9.
passed = _run_check(second, table, sc, account)
if not passed:
return _invalid("bad-check-digit", True)
return UkSortCodeAccount(valid=True, sort_code=sc, account_number=account, checked=True, reason=None)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, 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-sort-code-account
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.uk-sort-code-account-3.0.0-python.fune, or fetch it from a terminal with fune pull validation.uk-sort-code-account@3.0.0:python.
The whole function, every language, is one file too: validation.uk-sort-code-account-3.0.0.fune, 68,924 bytes, sha256 636ab9cf79494aeb1d6f17d59ab4a5237bae07a8fbabfc3a1c29c5faf45e00e7. 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-sort-code-account
after — your function gets the result and the arguments, and returns the final result.
# fune: after validation.uk-sort-code-account
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace validation.uk-modulus-table in validation.uk-sort-code-account
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-sort-code-account --steps.
# fune: step validation.uk-sort-code-account 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 | |
|---|---|---|---|
| MOD10 passes: 7+2+9+28+5+18+49+2 = 120 | 115000, 12345672, rows ×1, substitutions | → | valid true, sort code 115000, account number 12345672, checked true, reason — |
| MOD10 fails: 7+2+9+28+5+18+49+8 = 126 | 115000, 12345678, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| MOD11 passes: 8+14+18+20+20+18+14+9 = 121 = 11 x 11 | 120000, 12345679, rows ×1, substitutions | → | valid true, sort code 120000, account number 12345679, checked true, reason — |
| MOD11 fails: the total is 120, remainder 10 | 120000, 12345678, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| DBLAL adds the digits of each product (18 counts 9, 14 counts 5, 10 counts 1): 50 passes, where adding the products whole gives 77 | 130000, 98765436, rows ×1, substitutions | → | valid true, sort code 130000, account number 98765436, checked true, reason — |
| DBLAL: one more in h makes 51, which fails | 130000, 98765437, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| two rows: MOD11 (99) and DBLAL (40) both pass | 141414, 12345601, rows ×2, substitutions | → | valid true, sort code 141414, account number 12345601, checked true, reason — |
| two rows: MOD11 passes (110) but DBLAL fails (51), so invalid | 141414, 12345628, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| two rows: MOD11 fails but DBLAL passes, so invalid | 141414, 12345619, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| the first sort code of a range is in it | 110000, 12345672, rows ×2, substitutions | → | valid true, sort code 110000, account number 12345672, checked true, reason — |
Show the other 54 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the last sort code of a range is in it | 119999, 12345672, rows ×2, substitutions | → | valid true, sort code 119999, account number 12345672, checked true, reason — |
| a sort code between rows is in none: presumed valid, reported unchecked | 109999, 12345678, rows ×2, substitutions | → | valid true, sort code 109999, account number 12345678, checked false, reason — |
| an empty table checks nothing: every well-formed pair is unchecked | 089999, 66374958, rows , substitutions | → | valid true, sort code 089999, account number 66374958, checked false, reason — |
| a negative weight in a MOD11 row: -9+14+18+20+20+18+0+7 = 88 | 330000, 92345607, rows ×1, substitutions | → | valid true, sort code 330000, account number 92345607, checked true, reason — |
| exception 1: 27 is added to the DBLAL total, 33 + 27 = 60 | 210050, 12345606, rows ×1, substitutions | → | valid true, sort code 210050, account number 12345606, checked true, reason — |
| exception 1: 34 + 27 = 61 fails | 210050, 12345607, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 2 & 9: a is 0, so the row's own weights are used and pass | 220010, 02345604, rows ×2, substitutions | → | valid true, sort code 220010, account number 02345604, checked true, reason — |
| exception 2: a is not 0 and g is not 9, so the weights become 0 0 1 2 5 3 6 4 8 7 10 9 3 1 (176) | 220010, 12345601, rows ×2, substitutions | → | valid true, sort code 220010, account number 12345601, checked true, reason — |
| exception 2: a is not 0 and g is 9, so the weights become 0 0 0 0 0 0 0 0 8 7 10 9 3 1 | 220010, 12345694, rows ×2, substitutions | → | valid true, sort code 220010, account number 12345694, checked true, reason — |
| exception 2 fails, and exception 9 passes only because sort code 309634 replaces 220010 | 220010, 12345615, rows ×2, substitutions | → | valid true, sort code 220010, account number 12345615, checked true, reason — |
| exception 2 and exception 9 both fail | 220010, 12345600, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 3: c is 6, so the failing DBLAL check is skipped | 230020, 12645602, rows ×2, substitutions | → | valid true, sort code 230020, account number 12645602, checked true, reason — |
| exception 3: c is 1, so the DBLAL check runs and fails | 230020, 12145607, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 4: the remainder, 98 mod 11 = 10, equals gh | 240030, 12345610, rows ×1, substitutions | → | valid true, sort code 240030, account number 12345610, checked true, reason — |
| exception 4: the remainder 10 does not equal gh = 11 | 240030, 12345611, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 5 with substitution: 250050 is weighted as 250010; MOD11 124 gives g = 11 - 3 = 8, DBLAL 39 gives h = 10 - 9 = 1 | 250050, 12345681, rows ×2, substitutions ×1 | → | valid true, sort code 250050, account number 12345681, checked true, reason — |
| exception 5 without the substitution the same account fails | 250050, 12345681, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 5, a sort code with no substitute: both check digits right | 250020, 12345655, rows ×2, substitutions ×1 | → | valid true, sort code 250020, account number 12345655, checked true, reason — |
| exception 5: a MOD11 remainder of 1 is always invalid | 250020, 12345903, rows ×2, substitutions ×1 | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 5: a MOD11 remainder of 0 needs g = 0 | 250020, 12346703, rows ×2, substitutions ×1 | → | valid true, sort code 250020, account number 12346703, checked true, reason — |
| exception 5: g right, h wrong | 250020, 12345650, rows ×2, substitutions ×1 | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 6: a is 5 and g equals h, a foreign currency account: valid but unchecked | 260060, 52345600, rows ×2, substitutions | → | valid true, sort code 260060, account number 52345600, checked false, reason — |
| exception 6: a is 5 but g differs from h, so it is checked and fails | 260060, 52345601, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 6: a is 3, so it is checked and fails | 260060, 32345600, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 7: g is 9, so weights u to b are zeroed and it passes | 270070, 12345690, rows ×1, substitutions | → | valid true, sort code 270070, account number 12345690, checked true, reason — |
| exception 7: g is not 9, the ordinary check passes | 270070, 12345613, rows ×1, substitutions | → | valid true, sort code 270070, account number 12345613, checked true, reason — |
| exception 8: checked as sort code 090126, 58 + 107 = 165 | 280080, 12345603, rows ×1, substitutions | → | valid true, sort code 280080, account number 12345603, checked true, reason — |
| exception 8: two more in h makes 167, which fails | 280080, 12345604, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 10 & 11: the first fails, the second passes, either is enough | 300030, 12345601, rows ×2, substitutions | → | valid true, sort code 300030, account number 12345601, checked true, reason — |
| exception 10: ab is 99 and g is 9, so weights u to b are zeroed (121) and it passes | 300030, 99345690, rows ×2, substitutions | → | valid true, sort code 300030, account number 99345690, checked true, reason — |
| exception 10: ab is 09 and g is 9, the same | 300030, 09345690, rows ×2, substitutions | → | valid true, sort code 300030, account number 09345690, checked true, reason — |
| exception 12 & 13: MOD11 fails, MOD10 passes, either is enough | 310010, 12345614, rows ×2, substitutions | → | valid true, sort code 310010, account number 12345614, checked true, reason — |
| exception 12 & 13: both fail | 310010, 12345600, rows ×2, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| exception 14: fails (107), h is 9, so 01234560 is checked instead (77) and passes | 320040, 12345609, rows ×1, substitutions | → | valid true, sort code 320040, account number 12345609, checked true, reason — |
| exception 14: h is 0, the same retry passes | 320040, 12345600, rows ×1, substitutions | → | valid true, sort code 320040, account number 12345600, checked true, reason — |
| exception 14: h is 2, so there is no retry even though the shifted number would pass | 320040, 12345602, rows ×1, substitutions | → | valid false, sort code —, account number —, checked true, reason bad-check-digit |
| a sort code with hyphens and an account number with a space | 11-50-00, 1234 5672, rows ×1, substitutions | → | valid true, sort code 115000, account number 12345672, checked true, reason — |
| a seven-digit account number gets one leading zero: 0+7+12+15+16+15+12+0 = 77 | 120000, 1234560, rows ×1, substitutions | → | valid true, sort code 120000, account number 01234560, checked true, reason — |
| a six-digit account number gets two leading zeros: 0+0+18+20+20+18+14+9 = 99 | 120000, 345679, rows ×1, substitutions | → | valid true, sort code 120000, account number 00345679, checked true, reason — |
| nine digits needs the bank's own standardisation first | 120000, 123456789, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason non-standard-account |
| ten digits too | 120000, 0123456789, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason non-standard-account |
| five digits is too short to be any account number | 120000, 12345, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason bad-account-number |
| a letter in the account number | 120000, 1234567O, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason bad-account-number |
| non-ASCII digits in the account number | 120000, 1234567٩, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason bad-account-number |
| a five-digit sort code | 12000, 12345679, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason bad-sort-code |
| both empty reports the sort code first | , , rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason bad-sort-code |
| a dot is not a sort code separator | 12.00.00, 12345679, rows ×1, substitutions | → | valid false, sort code —, account number —, checked false, reason bad-sort-code |
| a row with 13 weights | 120000, 12345679, rows ×1, substitutions | → | error: modulus table row 1: expected 14 weights, found 13 |
| an unknown algorithm | 120000, 12345679, rows ×2, substitutions | → | error: modulus table row 2: unknown algorithm "MOD12" |
| a five-digit range start | 120000, 12345679, rows ×1, substitutions | → | error: modulus table row 1: start and end must be six-digit sort codes |
| a negative weight in a DBLAL row | 130000, 12345679, rows ×1, substitutions | → | error: modulus table row 1: a DBLAL row cannot have a negative weight |
| exception 15 does not exist | 120000, 12345679, rows ×1, substitutions | → | error: modulus table row 1: exception must be from 1 to 14 |
| a malformed table is an error even for a sort code it does not cover | 990000, 12345679, rows ×1, substitutions | → | error: modulus table row 1: exception must be from 1 to 14 |
| a substitution with a short sort code | 250020, 12345655, rows ×2, substitutions ×1 | → | error: modulus table substitution 1: original and substitute must be six-digit sort codes |
More from the author
## You supply the weight table
This package is the algorithm only. The modulus weight table it checks against, Vocalink's **VALACDOS.txt**, and exception 5's substitution table, **SCSUBTAB.txt**, are Vocalink's copyright and are not redistributed here.
1. Download both files from Vocalink's modulus checking page, run for Pay.UK: <https://www.vocalink.com/tools/modulus-checking/>. 2. Parse them with `parseUkModulusTable` from `validation.uk-modulus-table`, once, when your application starts. 3. Pass the table to every call.
Vocalink updates VALACDOS.txt several times a year as banks add and retire sort codes; the page gives the date of each release. Fetch the new file when it changes. An out-of-date table answers "unchecked" for a new sort code rather than rejecting it.
TypeScript:
import { readFileSync } from "node:fs";
import { parseUkModulusTable } from "#fune/validation.uk-modulus-table@^1";
import { validateUkSortCodeAccount } from "#fune/validation.uk-sort-code-account@^3";
const table = parseUkModulusTable(
readFileSync("data/valacdos.txt", "utf8"),
readFileSync("data/scsubtab.txt", "utf8"),
);
const result = validateUkSortCodeAccount("08-99-99", "66374958", table);
if (!result.valid) console.log(result.reason); // "bad-check-digit", ...Python:
from pathlib import Path
from fune.validation.uk_modulus_table import parse_uk_modulus_table # validation.uk-modulus-table@^1
from fune.validation.uk_sort_code_account import validate_uk_sort_code_account # validation.uk-sort-code-account@^3
table = parse_uk_modulus_table(
Path("data/valacdos.txt").read_text(encoding="utf-8"),
Path("data/scsubtab.txt").read_text(encoding="utf-8"),
)
result = validate_uk_sort_code_account("08-99-99", "66374958", table)
if not result.valid:
print(result.reason)Rust:
fune!(validation.uk-modulus-table@^1);
fune!(validation.uk-sort-code-account@^3);
fn main() -> std::io::Result<()> {
let table = parse_uk_modulus_table(
&std::fs::read_to_string("data/valacdos.txt")?,
&std::fs::read_to_string("data/scsubtab.txt")?,
);
let result = validate_uk_sort_code_account("08-99-99", "66374958", &table);
if !result.valid {
println!("{}", result.reason.unwrap_or_default());
}
Ok(())
}## The result
`valid`, the standardised `sortCode` (six digits) and `accountNumber` (eight digits), `checked`, and a `reason` when invalid. The two numbers are null when `valid` is false.
`checked` is false in two cases where the specification says to presume the account valid because nothing can be checked: the sort code is in no range of the weight table, or exception 6 applies (a foreign currency account at a sorting code that holds them). Callers who want to treat "unchecked" more cautiously can. An empty table leaves everything unchecked, which is one reason the parser refuses an empty VALACDOS.txt.
| reason | meaning | |---|---| | `bad-sort-code` | not six digits once spaces and hyphens are removed (checked first) | | `bad-account-number` | not 6 to 10 digits once spaces and hyphens are removed | | `non-standard-account` | 9 or 10 digits: must be standardised per bank first (below) | | `bad-check-digit` | the modulus check failed |
A bad sort code or account number is an answer, never an error. The function throws only for a malformed table (one built or edited by hand: a row without 14 weights, an unknown algorithm, a range that is not two six-digit sort codes, a negative DBLAL weight, an exception outside 1 to 14, or a substitution that is not two six-digit sort codes), naming the row.
## Account number length
Six and seven digit account numbers are left-padded with zeros, which the specification applies to every bank. Nine and ten digit numbers are refused with `non-standard-account`, because their standardisation depends on the bank (NatWest keeps the last eight digits, Co-operative and Leeds the first eight, Santander moves the first digit into the sort code) and nothing here knows which bank a sort code belongs to without the EISCD. Standardise them and call again.
## The algorithm
Find the row(s) of the weight table whose range contains the sort code. Each names MOD10, MOD11 or DBLAL (double alternate), fourteen weights for the digits u-z (sort code) and a-h (account), and an optional exception. Multiply digit by weight; MOD10 and MOD11 add the products, DBLAL adds the digits of the products; the total must divide by the modulus. With two rows, both checks must pass, except:
- 1: add 27 to the DBLAL total. - 2 and 9: if a is not 0 replace the weights (two sets, by whether g is 9); if that fails, check again against sort code 309634 (Lloyds euro accounts). Either passing is enough. - 3: skip the second check when c is 6 or 9. - 4: the MOD11 remainder must equal the two-digit number gh. - 5: substitute the sort code from the table's substitutions (SCSUBTAB.txt) for both checks; the MOD11 check digit is g and the DBLAL check digit is h: a MOD11 remainder of 0 needs g = 0, of 1 is always invalid, otherwise g = 11 - remainder; a DBLAL remainder of 0 needs h = 0, otherwise h = 10 - remainder. - 6: a in 4-8 with g equal to h is a foreign currency account: unchecked. - 7: if g is 9, zero the weights u to b. - 8: check against sort code 090126. - 10 and 11: either passing is enough; for 10, if ab is 09 or 99 and g is 9, zero the weights u to b. - 12 and 13: either passing is enough. - 14 (Coutts): if MOD11 fails and h is 0, 1 or 9, drop h, put a 0 in front and check again.
A MOD11 row may carry a negative weight; its remainder is always taken as 0 to 10, so the three languages agree.
## Tests
The vectors use small invented tables, not Vocalink's: made-up sort code ranges (110000-119999, 220000-220099 and so on) with weights chosen so each algorithm and each exception is exercised, passing and failing, and every expected answer worked by hand from the specification's description of the algorithm. They are not the test cases in chapter 3 of the specification, which depend on the real table. To check your own copy of VALACDOS.txt, run those cases against it in your application's tests.
## Changes
3.0.0 takes the weight table as a third argument instead of bundling Vocalink's VALACDOS.txt and SCSUBTAB.txt, which may not be redistributed. Parse the files with `validation.uk-modulus-table`. The result and its reasons are unchanged; the function now throws for a malformed table.
## Sources
- Vocalink, "Validating account numbers: UK Modulus Checking", section 2 (the algorithms and exceptions), via the modulus checking page: <https://www.vocalink.com/tools/modulus-checking/>
The specification also says to confirm the sort code exists in the EISCD first. That directory is licensed and not shipped here.
Files
| Path | Bytes |
|---|---|
| README.md | 7,375 |
| impl/python.py | 8,145 |
| impl/rust.rs | 10,007 |
| impl/typescript.ts | 8,108 |
| vectors.json | 28,076 |