validation.iban
Check an IBAN against the ISO 13616 mod-97-10 checksum and the registered length for its country.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 22 tests, run in TypeScript, Python and Rust.
What it does
A VALID IBAN IS NOT AN EXISTING ACCOUNT. The checksum proves the string was typed correctly; it says nothing about whether the account is open, whether the bank exists, or whose name is on it. Paying the wrong person is almost always a name mismatch rather than a checksum failure, so this belongs in front of a Confirmation of Payee check, not instead of one.
THE CHECK: strip spaces, fold to upper case, move the first four characters to the end, map A-Z to 10-35, and require the resulting decimal string to be 1 modulo 97. The expansion of a 34-character IBAN is up to 68 digits, which no 64-bit integer can hold, so all three implementations carry the remainder forward one character at a time. A letter contributes two digits and so multiplies the running remainder by 100; the largest intermediate value is 96 * 100 + 35 = 9635. Python could have used a big integer and Rust could not, so Python uses the chunked form too - the point of the registry is that the three languages run the same algorithm, not merely reach the same answer today.
For example
is_iban(GB82 WEST 1234 5698 7654 32)→ true the published ISO 13616 example for the United Kingdomis_iban(gb82west12345698765432)→ true the same IBAN unspaced and in lower caseis_iban(DE89 3704 0044 0532 0130 00)→ true the published German example
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_iban(value: str) -> bool
| value | string | An IBAN, optionally printed in groups of four separated by spaces |
| returns | bool |
Your code names it in one line, in the file that uses it
from fune.validation.iban import is_iban # validation.iban@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Optional
from .validation_iban_data import IBAN_LENGTHS ← this capability’s own data, compiled from data/iban-lengths.json into the same file by fune build
#: ISO 13616 allows 34 characters at most; Norway's 15 is the shortest issued.
_MAX_IBAN = 34
_MIN_IBAN = 15
def _is_digit(ch: str) -> bool:
return "0" <= ch <= "9"
def _is_upper_letter(ch: str) -> bool:
return "A" <= ch <= "Z"
def _compact(value: str) -> str:
"""Strip spaces and fold to upper case, ASCII only.
``str.upper()`` is Unicode-aware and would disagree with the Rust sibling
on inputs like "ß". Nothing in an IBAN is non-ASCII.
"""
out: List[str] = []
for ch in value:
# Only the ASCII space is stripped. IBANs are printed in groups of four
# and pasted that way; hyphens and other punctuation are not a printing
# convention, they are a sign the value came from somewhere unexpected.
if ch == " ":
continue
out.append(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch)
return "".join(out)
def iban_length(country: str) -> int:
"""The registered IBAN length for a country, or -1 if the country has none."""
code = _compact(country)
for row in IBAN_LENGTHS:
if row.country == code:
return row.length
return -1
def is_iban(value: str) -> bool:
"""Is this a structurally valid IBAN?
Two checks, both necessary. The ISO 13616 mod-97-10 checksum catches
mistyped and transposed characters, but on its own it would accept a
correctly-checksummed string of any length; the country's registered
length is what catches a truncated or padded account number that still
happens to check out.
Neither check proves the account exists. Only the bank can say that, and
only a payment (or a confirmation-of-payee service) proves it belongs to
the person you think it does.
"""
if not isinstance(value, str):
return False
iban = _compact(value)
if len(iban) < _MIN_IBAN or len(iban) > _MAX_IBAN:
return False
# Positions 1-2 are the country, 3-4 the check digits. Testing this before
# the table lookup means a lower-case or punctuated value fails here rather
# than being reported as an unknown country.
if not _is_upper_letter(iban[0]) or not _is_upper_letter(iban[1]):
return False
if not _is_digit(iban[2]) or not _is_digit(iban[3]):
return False
expected = iban_length(iban[0:2])
if expected < 0 or len(iban) != expected:
return False
for ch in iban[4:]:
if not _is_digit(ch) and not _is_upper_letter(ch):
return False
return _mod97(iban) == 1
def _mod97(iban: str) -> int:
"""ISO 13616 mod-97-10: move the first four characters to the end, replace
each letter with its position in the alphabet plus 9 (A=10 ... Z=35), and
take the whole thing modulo 97.
Python would happily hold the 68-digit integer, but the remainder is
carried forward one character at a time so that this implementation is the
same algorithm as the Rust one, where a 68-digit integer is not an option.
A letter contributes two digits, so it multiplies the running remainder by
100; the largest intermediate is 96 * 100 + 35 = 9635.
"""
remainder = 0
n = len(iban)
for i in range(n):
# Rotation without building a second string: read from position 4
# onwards, then wrap round to the first four characters.
ch = iban[(i + 4) % n]
if _is_digit(ch):
remainder = (remainder * 10 + (ord(ch) - 48)) % 97
else:
remainder = (remainder * 100 + (ord(ch) - 55)) % 97
return remainder
def format_iban(value: str) -> Optional[str]:
"""The IBAN in its printed form, groups of four separated by single spaces,
or None if it does not validate.
"""
if not is_iban(value):
return None
iban = _compact(value)
return " ".join(iban[i : i + 4] for i in range(0, len(iban), 4))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.iban
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.iban-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.iban@1.0.0:python.
The whole function, every language, is one file too: validation.iban-1.0.0.fune, 25,944 bytes, sha256 9bfdfb5bf97b782d91dfc079fedfee84e43d92fe1568b402fc5e45584227d8a4. 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.iban
after — your function gets the result and the arguments, and returns the final result.
# fune: after validation.iban
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.iban --steps.
# fune: step validation.iban 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 | |
|---|---|---|---|
| the published ISO 13616 example for the United Kingdom | GB82 WEST 1234 5698 7654 32 | → | true |
| the same IBAN unspaced and in lower case | gb82west12345698765432 | → | true |
| the published German example | DE89 3704 0044 0532 0130 00 | → | true |
| a French IBAN, whose BBAN contains letters | FR14 2004 1010 0505 0001 3M02 606 | → | true |
| Malta, one of the longest registered lengths at 31 | MT84 MALT 0110 0001 2345 MTLC AST0 01S | → | true |
| Norway, the shortest registered length at 15 | NO93 8601 1117 947 | → | true |
| Russia, 33 characters, well past a 64-bit integer once expanded | RU02 0445 2560 0407 0281 0412 3456 7890 1 | → | true |
| Saint Lucia, 32 characters and letter-heavy | LC55 HEMM 0001 0001 0012 0012 0002 3015 | → | true |
| a Spanish IBAN | ES91 2100 0418 4502 0005 1332 | → | true |
| a Swiss IBAN | CH93 0076 2011 6238 5295 7 | → | true |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a Belgian IBAN, the shortest in the euro area at 16 | BE68 5390 0754 7034 | → | true |
| a second UK IBAN, so the table is not pinned by one bank | GB29 NWBK 6016 1331 9268 19 | → | true |
| the empty string is not an IBAN | → | false | |
| right shape and length, check digits off by one | GB82 WEST 1234 5698 7654 33 | → | false |
| two digits of the account number transposed | GB82 WEST 1234 5698 7645 32 | → | false |
| one character short for the United Kingdom | GB82WEST1234569876543 | → | false |
| a country code that is not in the IBAN registry | ZZ82WEST12345698765432 | → | false |
| the check digits are letters | GBXX WEST 1234 5698 7654 32 | → | false |
| hyphens are not an accepted separator | GB82-WEST-1234-5698-7654-32 | → | false |
| the United States has no IBAN, whatever the length | US64SVBKUS6S3300958879 | → | false |
| a lower case country code alone is not enough to fail | Gb82 west 1234 5698 7654 32 | → | true |
| a non-string argument is not an IBAN | 82 | → | false |
More from the author
THE LENGTH TABLE IS DATA, NOT CODE. data/iban-lengths.json maps each registered country code to its exact IBAN length, and it is the second half of the validation: mod-97-10 on its own accepts a correctly-checksummed string of any length, so a truncated or padded account number can slip through it. SWIFT publishes a new IBAN registry release roughly twice a year, adding countries and occasionally changing a length. When that happens, publish a new version of this capability's data. No application code changes.
Countries absent from the table are rejected, which is the correct answer for the United States, Canada, Australia and everywhere else that never adopted IBAN, and also the correct answer for a country added to the registry after this data release - a false negative that a data update fixes, rather than a false positive that nobody notices.
ACCEPTED INPUT: upper or lower case, with or without the conventional spaces every four characters, including leading and trailing spaces. Nothing else is stripped: hyphens, dots and non-breaking spaces make the value invalid rather than being skipped, because they mean the value came from somewhere other than a printed IBAN.
OUT OF SCOPE: the country-specific BBAN structure. The UK's IBAN embeds a four-letter bank code, a six-digit sort code and an eight-digit account number, and this capability does not check that inner shape, only the overall length and the checksum. It also does not reject the reserved check digits 00, 01 and 99 explicitly; the mod-97 test already fails every such value.
Validators answer rather than throw: an unparseable value is not an exceptional condition, it is the answer "no".
Files
| Path | Bytes |
|---|---|
| README.md | 2,748 |
| data/iban-lengths.json | 5,137 |
| impl/python.py | 3,949 |
| impl/rust.rs | 4,308 |
| impl/typescript.ts | 4,024 |
| vectors.json | 2,525 |