IBAN validation in Python, PHP and Java: MOD-97 step by step

The check digits of an IBAN are a MOD 97-10 remainder. This post walks through the arithmetic once, then gives short, tested implementations in Python and PHP, a Java version, and the libraries to use when you need country-specific rules.

By the Generate Random IBAN editorial team · · 4 min read

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:

  1. Characters and shape: only letters and digits after you strip spaces; letters in positions 1 and 2; digits in positions 3 and 4.
  2. Length for the country (22 for DE, 27 for FR, and so on). A check-digit test alone accepts an IBAN with a digit missing one time in 97, so the length check matters.
  3. 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.

  1. Remove spaces and uppercase: GB29NWBK60161331926819.
  2. Move the first four characters to the end: NWBK60161331926819GB29.
  3. Replace every letter with two digits, A = 10 up to Z = 35. N = 23, W = 32, B = 11, K = 20, G = 16: 2332112060161331926819161129 (28 digits).
  4. 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's BigInteger, PHP's bcmod() (needs the bcmath extension).
  • Divide in pieces. Because the remainder of (a * 10^k + b) mod 97 only depends on a 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: 2332112 gives 38; 380601613 gives 94; 943192681 gives 86; 869161129 gives 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) has stdnum.iban with is_valid(), validate(), compact(), format() and calc_check_digits(). validate('GR16 0110 1050 0000 1054 7023 795') returns the compact form or raises; calc_check_digits('BExx435411161155') returns '31'. The check_country flag (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 exposes bank_code, account_code, formatted and, for countries where it can derive it, bic. IBAN.random(country_code="GB") and IBAN.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 with IbanUtil.validate(String) and throws IbanFormatException, InvalidCheckDigitException or UnsupportedCountryException, 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, and Iban.random(CountryCode.AT) is useful in tests.
  • Apache Commons Validator (commons-validator:commons-validator, 1.11.0 at the time of writing) ships IBANValidator.getInstance().isValid(code). It checks the country code, the registered length and pattern, and the check digits via IBANCheckDigit. 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

Check an IBAN now

Paste an IBAN to check its length, format, MOD-97 check digits and, for 27 countries, the national check digits. It runs in your browser.

Open the IBAN validator