IBAN validation in Python, PHP or Java comes down to one calculation defined in ISO 13616: rearrange the IBAN, turn its letters into numbers, divide by 97 and expect a remainder of 1. Everything else (country lengths, national check digits, bank code lookups) sits on top of that. This post does the arithmetic once, by hand, then shows idiomatic code in the three languages and names the libraries that already handle the per-country rules.
If you work in JavaScript, the earlier post https://generaterandomiban.com/en/blog/validate-iban-javascript covers the same check in the browser and in Node.
What "valid" means for an IBAN
An IBAN has four parts: a two-letter country code, two check digits, and a national part called the BBAN that can be up to 30 characters long, for 34 in total. The country decides the BBAN layout and the total length: a German IBAN is always 22 characters, a French one 27, a Maltese one 31. The registry maintained by SWIFT lists every format; https://generaterandomiban.com/en/ibanpedia/iban-formats-by-country has the table.
A validator usually checks three things in order:
- Characters and shape: only letters and digits after you strip spaces; letters in positions 1 and 2; digits in positions 3 and 4.
- Length for the country (22 for
DE, 27 forFR, and so on). A check-digit test alone accepts an IBAN with a digit missing one time in 97, so the length check matters. - The check digits, with the MOD 97-10 method from ISO/IEC 7064.
Passing all three says the string is well formed and was not mistyped. It does not say an account exists, and it does not run the national check digits that some countries add inside the BBAN (Italy, France, Spain, Belgium and others). https://generaterandomiban.com/en/ibanpedia/national-check-digits explains those, and the libraries in this post cover them for you.
MOD 97-10 step by step
Take the UK example from the IBAN registry, GB29 NWBK 6016 1331 9268 19.
- Remove spaces and uppercase:
GB29NWBK60161331926819. - Move the first four characters to the end:
NWBK60161331926819GB29. - Replace every letter with two digits,
A= 10 up toZ= 35.N= 23,W= 32,B= 11,K= 20,G= 16:2332112060161331926819161129(28 digits). - Divide by 97. The remainder is 1, so the IBAN is valid.
To compute the check digits for a new IBAN, do the same with 00 in place of the check digits, then subtract the remainder from 98. For GB00NWBK60161331926819 the rearranged number is 2332112060161331926819161100, the remainder is 69, and 98 minus 69 is 29. Pad to two digits if the result is below 10. By construction, the full IBAN then gives remainder 1.
The big-number problem
Step 4 divides a number of up to 38 digits by 97, which overflows a 64-bit integer. There are two standard ways around it:
- Use arbitrary-precision integers: Python's
int, Java'sBigInteger, PHP'sbcmod()(needs thebcmathextension). - Divide in pieces. Because the remainder of
(a * 10^k + b) mod 97only depends ona mod 97, you can take the running remainder, append the next few digits and reduce again. Seven-digit chunks keep every intermediate value below 10^9. For the example:2332112gives 38;380601613gives 94;943192681gives 86;869161129gives 1.
The chunked version works with plain integers in any language, so it is the one I use where a dependency would be awkward.
Python
Python integers are unbounded, so the direct version is the clearest. The lookup table holds a few lengths; in a real project load all of them from the registry, or use one of the libraries below.
import re
IBAN_LENGTHS = {"DE": 22, "FR": 27, "GB": 22, "IT": 27, "ES": 24, "NL": 18}
def normalise(iban: str) -> str:
"""Electronic format: uppercase, no spaces or separators."""
return re.sub(r"[\s\-]", "", iban).upper()
def mod97(iban: str) -> int:
"""Remainder of the rearranged, digit-converted IBAN divided by 97."""
rearranged = iban[4:] + iban[:4]
digits = "".join(str(int(ch, 36)) for ch in rearranged)
return int(digits) % 97
def is_valid_iban(iban: str, check_length: bool = True) -> bool:
iban = normalise(iban)
if not re.fullmatch(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}", iban):
return False
if check_length and IBAN_LENGTHS.get(iban[:2], len(iban)) != len(iban):
return False
return mod97(iban) == 1
def iban_check_digits(country: str, bban: str) -> str:
"""Compute the two check digits for a country code and BBAN."""
remainder = mod97(country.upper() + "00" + bban.upper())
return f"{98 - remainder:02d}"
print(is_valid_iban("GB29 NWBK 6016 1331 9268 19")) # True
print(is_valid_iban("GB29 NWBK 6016 1331 9268 18")) # False
print(iban_check_digits("GB", "NWBK60161331926819")) # 29
int(ch, 36) is the whole letter conversion: it reads one character in base 36, so '0' to '9' map to 0 to 9 and 'A' to 'Z' map to 10 to 35, exactly the ISO 13616 table. The regular expression rejects lowercase before that line runs, because normalise() has already uppercased the input. I ran this file with Python 3.12 and also compared mod97() with a chunked version on a few thousand random strings.
Libraries
- python-stdnum (
pip install python-stdnum) hasstdnum.ibanwithis_valid(),validate(),compact(),format()andcalc_check_digits().validate('GR16 0110 1050 0000 1054 7023 795')returns the compact form or raises;calc_check_digits('BExx435411161155')returns'31'. Thecheck_countryflag (on by default) also checks the country-specific BBAN layout, and a handful of national checksums. - schwifty (
pip install schwifty) is object based:IBAN('DE89 3704 0044 0532 0130 00')parses and validates,iban.validate(validate_bban=True)adds the national checksum where one exists, and errors come as typed exceptions (InvalidCountryCode,InvalidChecksumDigits,InvalidBBANChecksum). It exposesbank_code,account_code,formattedand, for countries where it can derive it,bic.IBAN.random(country_code="GB")andIBAN.generate('DE', bank_code=..., account_code=...)are handy in tests.
PHP
PHP integers are 64-bit, so the chunked division is the safe choice when you cannot rely on bcmath being installed. If you can, bcmod($digits, '97') === '1' replaces the loop.
<?php
declare(strict_types=1);
const IBAN_LENGTHS = ['DE' => 22, 'FR' => 27, 'GB' => 22, 'IT' => 27, 'ES' => 24, 'NL' => 18];
/** Electronic format: uppercase, no spaces or separators. */
function normaliseIban(string $iban): string
{
return strtoupper((string) preg_replace('/[\s\-]/', '', $iban));
}
/** Remainder of the rearranged, digit-converted IBAN divided by 97 (no bcmath needed). */
function ibanMod97(string $iban): int
{
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
$digits = '';
foreach (str_split($rearranged) as $ch) {
$digits .= ctype_alpha($ch) ? (string) (ord($ch) - 55) : $ch;
}
$remainder = 0;
foreach (str_split($digits, 7) as $chunk) {
$remainder = (int) ($remainder . $chunk) % 97;
}
return $remainder;
}
function isValidIban(string $iban, bool $checkLength = true): bool
{
$iban = normaliseIban($iban);
if (! preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/', $iban)) {
return false;
}
if ($checkLength && (IBAN_LENGTHS[substr($iban, 0, 2)] ?? strlen($iban)) !== strlen($iban)) {
return false;
}
return ibanMod97($iban) === 1;
}
/** The two check digits for a country code and BBAN. */
function ibanCheckDigits(string $country, string $bban): string
{
$remainder = ibanMod97(strtoupper($country) . '00' . strtoupper($bban));
return str_pad((string) (98 - $remainder), 2, '0', STR_PAD_LEFT);
}
var_dump(isValidIban('GB29 NWBK 6016 1331 9268 19')); // bool(true)
var_dump(isValidIban('GB29 NWBK 6016 1331 9268 18')); // bool(false)
echo ibanCheckDigits('GB', 'NWBK60161331926819'), PHP_EOL; // 29
ord('A') - 55 is 10, ord('Z') - 55 is 35, which is why the subtraction works for uppercase letters only; normaliseIban() guarantees that. I ran this on PHP 8.3 and checked ibanMod97() against bcmod() on random inputs. One detail: in PCRE, \d without the u modifier matches ASCII digits only, which is the behaviour you want here (the Python version spells it [0-9] for the same reason, because Python's \d also matches digits from other scripts).
Library
php-iban (composer require globalcitizen/php-iban, LGPL-3.0, version 5.0.0 released in September 2026) is the long-standing PHP option. verify_iban($iban) accepts both the printed and the electronic form, checks the country's registered BBAN layout and the check digits, and iban_to_machine_format() / iban_to_human_format() convert between the two formats. It also splits the parts (iban_get_bank_part(), iban_get_branch_part()) and has an object wrapper, new IBAN($string) with ->Verify(). The generator on this site uses the same registry data in its own PHP code, which is how the IBANs it produces always pass verify_iban().
Java
BigInteger makes the direct version short. The code below is deliberately minimal (no JDK was available where this post was written, so, unlike the Python and PHP versions, it was checked by reading rather than by running).
import java.math.BigInteger;
import java.util.Locale;
import java.util.Map;
public final class Iban {
private static final Map<String, Integer> LENGTHS =
Map.of("DE", 22, "FR", 27, "GB", 22, "IT", 27, "ES", 24, "NL", 18);
public static String normalise(String iban) {
return iban.replaceAll("[\\s-]", "").toUpperCase(Locale.ROOT);
}
public static int mod97(String iban) {
String rearranged = iban.substring(4) + iban.substring(0, 4);
StringBuilder digits = new StringBuilder();
for (char ch : rearranged.toCharArray()) {
digits.append(Character.digit(ch, 36)); // '0'..'9' -> 0..9, 'A'..'Z' -> 10..35
}
return new BigInteger(digits.toString()).mod(BigInteger.valueOf(97)).intValue();
}
public static boolean isValid(String input) {
String iban = normalise(input);
if (!iban.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
return false;
}
Integer expected = LENGTHS.get(iban.substring(0, 2));
if (expected != null && expected != iban.length()) {
return false;
}
return mod97(iban) == 1;
}
public static String checkDigits(String country, String bban) {
int remainder = mod97(country.toUpperCase(Locale.ROOT) + "00" + bban.toUpperCase(Locale.ROOT));
return String.format("%02d", 98 - remainder);
}
}
Character.digit(ch, 36) plays the role of Python's int(ch, 36). Locale.ROOT keeps toUpperCase() from turning i into a dotted capital under a Turkish default locale. String.matches() anchors the pattern to the whole string, so no ^ and $ are needed.
Libraries
- iban4j (
org.iban4j:iban4j, Apache 2.0, Java 11 or later) validates withIbanUtil.validate(String)and throwsIbanFormatException,InvalidCheckDigitExceptionorUnsupportedCountryException, so the caller can tell a typo from an unknown country.Iban.valueOf()parses,new Iban.Builder().countryCode(CountryCode.AT).bankCode("19043").accountNumber("00234573201").build()constructs one with correct check digits, andIban.random(CountryCode.AT)is useful in tests. - Apache Commons Validator (
commons-validator:commons-validator, 1.11.0 at the time of writing) shipsIBANValidator.getInstance().isValid(code). It checks the country code, the registered length and pattern, and the check digits viaIBANCheckDigit. Its documentation is explicit that it does not check the national rules inside the BBAN.
Test cases to run against any of them
Whichever version you ship, run it against the same short list. The IBANs are the examples from the SWIFT registry, so they are valid by definition, and https://generaterandomiban.com/en/ibanpedia/test-ibans has more of them.
| Input | Expected | What it proves |
|---|---|---|
GB29 NWBK 6016 1331 9268 19 |
valid | spaces are ignored, letters in the BBAN work |
gb29nwbk60161331926819 |
valid | input is uppercased before checking |
GB29NWBK60161331926818 |
invalid | a single changed digit changes the remainder |
GB29NWBK60161331926891 |
invalid | the swapped last two digits are caught |
DE8937040044053201300 |
invalid | 21 characters: wrong length for Germany, caught before the arithmetic |
DE89 3704 0044 0532 0130 0O |
invalid | letter O instead of zero fails the shape test |
MT84MALT011000012345MTLCAST001S |
valid | a 31-character IBAN with letters at the end |
XX12ABCD1234 |
invalid | unknown country code |
ISO/IEC 7064 describes its check character systems as catching all single substitution errors and all or nearly all single transposition errors, which is what the third and fourth rows exercise. The https://generaterandomiban.com/en/blog/how-to-test-iban-validation post has a longer checklist for QA, including what to do about national check digits and realistic bank codes.
To try an implementation against random inputs rather than fixed ones, generate a batch from the https://generaterandomiban.com generator (it builds structurally valid IBANs for 91 countries, with real bank codes by default) and run them through your code. The same arithmetic, in the browser, is behind the validator below.
Sources
- ISO 13616-1:2020: the structure of the IBAN, the letter-to-digit conversion and the use of MOD 97-10 for the check digits.
- ISO/IEC 7064:2003: the MOD 97-10 check character system and the error classes it detects.
- SWIFT IBAN Registry, Release 102 (June 2026): country lengths and the example IBANs used above.
- python-stdnum, stdnum.iban: function list and examples.
- schwifty documentation: API and exception names.
- php-iban on GitHub: functions, licence and release history.
- iban4j on GitHub: coordinates, exceptions and builder API.
- Apache Commons Validator, IBANValidator: what the class checks.