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.
pub fn 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
fune!(validation.iban@^1); // then call is_iban(…)
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
use super::validation_iban_data::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.
const MAX_IBAN: usize = 34;
const MIN_IBAN: usize = 15;
fn is_digit(ch: char) -> bool {
('0'..='9').contains(&ch)
}
fn is_upper_letter(ch: char) -> bool {
('A'..='Z').contains(&ch)
}
/// Strip spaces and fold to upper case, ASCII only, so that this agrees with
/// the TypeScript and Python siblings whose built-in case folding is
/// Unicode-aware. Nothing in an IBAN is non-ASCII.
fn compact(value: &str) -> Vec<char> {
value
.chars()
// 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.
.filter(|ch| *ch != ' ')
.map(|ch| ch.to_ascii_uppercase())
.collect()
}
/// The registered IBAN length for a country, or -1 if the country has none.
pub fn iban_length(country: &str) -> i64 {
let code: String = compact(country).into_iter().collect();
for row in IBAN_LENGTHS {
if row.country == code {
return row.length;
}
}
-1
}
/// 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.
pub fn is_iban(value: &str) -> bool {
let iban = compact(value);
if iban.len() < MIN_IBAN || iban.len() > 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 !is_upper_letter(iban[0]) || !is_upper_letter(iban[1]) {
return false;
}
if !is_digit(iban[2]) || !is_digit(iban[3]) {
return false;
}
let country: String = iban[0..2].iter().collect();
let expected = iban_length(&country);
if expected < 0 || iban.len() as i64 != expected {
return false;
}
for ch in &iban[4..] {
if !is_digit(*ch) && !is_upper_letter(*ch) {
return false;
}
}
mod97(&iban) == 1
}
/// 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.
///
/// The expansion of a 34-character IBAN is up to 68 digits, which no 64-bit
/// integer can hold, so the remainder is carried forward one character at a
/// time. A letter contributes two digits, so it multiplies the running
/// remainder by 100; the largest intermediate is 96 * 100 + 35 = 9635, nowhere
/// near an overflow.
fn mod97(iban: &[char]) -> i64 {
let mut remainder: i64 = 0;
let n = iban.len();
for i in 0..n {
// Rotation without building a second string: read from position 4
// onwards, then wrap round to the first four characters.
let ch = iban[(i + 4) % n];
if is_digit(ch) {
remainder = (remainder * 10 + (ch as i64 - 48)) % 97;
} else {
remainder = (remainder * 100 + (ch as i64 - 55)) % 97;
}
}
remainder
}
/// The IBAN in its printed form, groups of four separated by single spaces,
/// or `None` if it does not validate.
pub fn format_iban(value: &str) -> Option<String> {
if !is_iban(value) {
return None;
}
let iban = compact(value);
let groups: Vec<String> = iban.chunks(4).map(|c| c.iter().collect()).collect();
Some(groups.join(" "))
}
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 an IBAN.
Value::Bool(is_iban(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.iban
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.iban-1.0.0-rust.fune, or fetch it from a terminal with fune pull validation.iban@1.0.0:rust.
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 |