Functional Weave
Code in Python

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 Kingdom
  • is_iban(gb82west12345698765432) → true the same IBAN unspaced and in lower case
  • is_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
valuestringAn IBAN, optionally printed in groups of four separated by spaces
returnsbool

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

from fune.validation.iban import is_iban  # validation.iban@^1
impl/python.py · 114 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.

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
Download for Python validation.iban-1.0.0-python.fune · 17,321 bytes sha256 1e9a3edd54afe45e8d2d01c3cb778d4f206fcc2ab8d04949b34038d678d325cf

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.

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

PathBytes
README.md2,748
data/iban-lengths.json5,137
impl/python.py3,949
impl/rust.rs4,308
impl/typescript.ts4,024
vectors.json2,525