Randomly

Identifiers

IBAN check digits: how mod-97 actually works

Randomly 8 min
IBAN check digits: how mod-97 actually works
On this page 0%

Mod-97 looks like a party trick until the first time a payment file comes back with reason code “incorrect IBAN.” The check digits are two numbers near the front of the string. They are not decoration. ISO 13616 wants the whole rearranged account to leave a remainder of 1 when you divide by 97. If it does not, the IBAN is garbage, even if it has the right length and a real country code.

I generate test IBANs in the browser so fixtures pass client-side validation without ever touching a live bank. That is the whole point of the IBAN generator and the IBAN validator. This post is the arithmetic, the country length traps, and the ways people try to “almost” implement the spec.

Four-step diagram of the IBAN mod-97 check: rearrange, expand letters, divide by 97, remainder 1
Rearrange, expand A–Z to 10–35, take mod 97, expect 1. That is the whole checksum.

The shape of an IBAN

Country code, two check digits, then a domestic BBAN. Germany is 22 characters. France is 27. Greece is 27. Malta is 31. There is no single “IBAN length.” If your regex is ^[A-Z]{2}\\d{2}[A-Z0-9]{11,30}$ you will accept junk and reject some real ones depending on how you counted. Prefer a catalog of country lengths. Ours lives in a JSON file of BBAN templates: N for digit, A for letter, C for alphanumeric.

Spaces in display are normal. Humans like DE89 3704 0044 0532 0130 00. Machines do not. Strip spaces and uppercase before you checksum. I have failed a validator because I checksummed the pretty form and treated the spaces as zeros. Do not do that.

How mod-97 is supposed to run

Move the first four characters to the end. Convert letters to numbers (A=10 … Z=35). Interpret the giant decimal integer. Compute that integer mod 97. A valid IBAN yields 1.

The integer is too big for a 64-bit int on long IBANs. This is where home-grown validators die. JavaScript Number is a float. You cannot parseInt a 30-digit string and hope. Use BigInt, or walk the string taking running ((acc * 10 + digit) % 97). The running method is what I prefer in browsers because it never builds a 60-digit BigInt if you do not want to. Both work if you are careful with letter expansion (two digits, not one).

I once saw a function that mapped A to 1. Off by nine, every time, and the tests used only numeric BBANs so it stayed green. Add a letter country to your tests. DE and FR are not enough if your bug is in the letter table. Try NL, or anything with letters in the bank code.

Tiles of country codes with their IBAN lengths, one country selected
Length is per country. A global “22 characters” rule is how you invent invalid Dutch IBANs.

Generating a valid test IBAN

Pick a country. Fill the BBAN from the template with random characters of the right class. Leave the check digits as 00. Run mod-97. You want check digits such that the remainder becomes 1. The usual trick: compute remainder of the 00 version, then set check digits to 98 - remainder, padded to two digits. If you get 98, use 00? Read the spec notes and test. I verify by running the validator on my own output. If generator and validator share a bug, you will ship a closed loop of wrongness. I keep the check implementation small and I test it against a few published examples from SWIFT-style docs.

Generated IBANs are format-valid. They are not accounts. Do not print them on an invoice. Do not SEPA-debit them. QA people will still try. Put a comment in the fixture: “synthetic, checksum only.”

What the checksum does not do

It does not prove the bank exists. It does not prove the person exists. It does not replace IBAN/BIC directories. A typo that still passes mod-97 is rare but possible; 97 is a small prime, not a hash. Transpositions are the thing it is good at catching. Random digit flips often fail. A carefully chosen wrong BBAN can still land on remainder 1.

Country code typos are fun. D1 is not a country. XX might pass length if you forgot to check the ISO list. Validate country against the catalog first, then checksum.

SEPA and the test data problem

If you build a European checkout, you will need IBANs in staging. Using a real IBAN “just for tests” is how those numbers leak into screenshots and then into search indexes. Generate fresh ones. Rotate them when you record demos. The generator can mint a handful per country. Pair them with the ABA routing generator only if you also have a US ACH path; they are different worlds that both show up in “bank field” tickets.

For card forms sitting next to IBAN forms, use the Luhn test cards. Same rule: checksum-valid, not charged.

Simple IBAN generator form with a sample number and generate and check buttons
Generate, then immediately run the check on the same string. If those two disagree, stop shipping.

Implementation footguns

Lowercase input. Normalize.

Electronic format versus paper format. Some countries print extra separators. Strip anything that is not A–Z or 0–9 before you start.

Using mod 97 on the original order. You must move the country and check digits to the tail. Skip that step and you will reject every real IBAN and not know why.

Testing only one country. BBAN templates differ. A generator that always emits 22 characters is a German generator with a fake globe icon.

Trusting a copy-pasted “IBAN regex from Stack Overflow.” I have seen one that required a space every four characters. Users who paste compact IBANs fail. Users who type spaces in the wrong place fail. Strip, then validate.

A tiny worked example

Take the well-known example GB82 WEST 1234 5698 7654 32. Compact it. Move GB82 to the end. Expand letters. Run the remainder. You should get 1. If you do not, your letter table or your BigInt path is wrong. I keep that example in unit tests even when the rest of the suite is random. Random tests find length bugs. Fixed examples find arithmetic bugs.

When I add a country, I add one official example if I can find one, plus a generated round-trip. Official examples go stale (banks merge). Round-trips stay honest about our own catalog.

What I use on Randomly

The generator expands a BBAN template, computes check digits, and prints the compact string. The validator checks length for that country and the remainder. Both run in the page. I do not call a bank API. If you need to know whether an account can receive money, that is a different product, with KYC, and I am not pretending this site is that product.

If you are also generating national IDs for the same checkout, read the PII-safe test data note before you paste SSNs into the same fixture file.

IBAN next to BIC

Checkout forms still ask for BIC/SWIFT on some rails. An IBAN already encodes a bank in many countries. Asking for both is legacy muscle memory. If you only needed a checksum-valid IBAN for a screenshot, do not also invent a BIC and then “validate” it with a regex you found in 2014. Empty BIC plus valid IBAN is more honest for SEPA-looking fixtures.

I keep a sticky note on staging: if a test IBAN ever matches a number a coworker recognizes, burn the fixture. Collisions with live accounts are unlikely if you randomize the BBAN, but “unlikely” is a lousy incident report.

One more operational habit. When support pastes an IBAN into Slack, redact the middle. Check digits plus country are enough to talk about a checksum bug. The rest is account-shaped data even when it is fake, because someone will treat it as real.

Frequently asked questions

Why 97?

It is a small prime that catches a lot of digit errors without a huge check field. ISO chose it. I did not.

Can two different BBANs share check digits?

Yes. Check digits are two digits. Many BBANs map to the same pair. Uniqueness is not the point. Error detection is.

Do UK IBANs still matter after Brexit?

Sterling payments have their own mess. IBANs still exist for GB. Validate GB like any other catalog entry. Do not delete it because of a headline.

Should I store spaces?

Store compact. Display grouped. Mixing those in the database is how you get duplicate accounts that are the same number.