ibanchecker.cash
Developer ResourcesJune 3, 2026 · 8 min read

IBAN Validation in Python: Complete Implementation with Regex and Check Digits

Two production-ready approaches: call the ibanchecker.cash API for bank metadata, or implement the MOD-97 algorithm yourself. Includes working code and pytest examples.

Share

IBAN validation in Python requires more than a regular expression. A structurally valid IBAN must pass a MOD-97 check digit calculation — which no regex can perform. This guide covers two production-ready approaches: calling the ibanchecker.cash API for instant results with bank metadata, and implementing the full validation algorithm yourself when you need offline or embedded validation.

Why IBAN Validation Is More Than a Regex Check

An IBAN has three validation layers you must apply in order:

  1. Format check: The string must start with a valid two-letter country code and consist of alphanumeric characters only.
  2. Length check: Each country has a fixed IBAN length. A German (DE) IBAN is always 22 characters. A UK (GB) IBAN is always 22. A French (FR) IBAN is always 27. Length varies by country.
  3. MOD-97 check: The ISO 13616 check digit algorithm must produce a remainder of 1 when applied to the rearranged IBAN.

Skipping layer 3 means your validator accepts IBANs with typos — the entire point of the check digit is to catch single-character substitution and transposition errors.

Approach 1: Validate via the ibanchecker.cash API

The fastest path to production-grade IBAN validation is the ibanchecker.cash API. One POST call returns whether the IBAN is valid, the bank name, BIC, country, and branch data — all in under 100 ms from European infrastructure.

import requests

def validate_iban_api(iban: str) -> dict:
    url = "https://ibanchecker.cash/api/v1/validate"
    response = requests.post(
        url,
        json={"iban": iban},
        headers={"Content-Type": "application/json"},
        timeout=5,
    )
    response.raise_for_status()
    return response.json()

# Example usage
result = validate_iban_api("DE89370400440532013000")
print(result)
# {
#   "valid": true,
#   "iban": "DE89 3704 0044 0532 0130 00",
#   "country": "Germany",
#   "bankName": "Deutsche Bank",
#   "bic": "DEUTDEDB",
#   "formatted": "DE89 3704 0044 0532 0130 00"
# }

if result["valid"]:
    print(f"Bank: {result.get('bankName')}")
    print(f"BIC:  {result.get('bic')}")
else:
    print(f"Invalid: {result.get('error')}")

For bulk validation (up to 100 IBANs per request), use the bulk endpoint:

def validate_ibans_bulk(ibans: list[str]) -> list[dict]:
    url = "https://ibanchecker.cash/api/v1/validate/bulk"
    response = requests.post(
        url,
        json={"ibans": ibans},
        headers={"Content-Type": "application/json"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()["results"]

results = validate_ibans_bulk([
    "DE89370400440532013000",
    "GB29NWBK60161331926819",
    "FR7630006000011234567890189",
])
for r in results:
    print(f"{r['iban']}: {'✓' if r['valid'] else '✗'}")

Approach 2: Manual MOD-97 Implementation

When you need offline validation — embedded systems, batch processing pipelines without external dependencies, or compliance environments with no outbound network calls — implement the ISO 13616 algorithm directly. The implementation is about 30 lines of Python.

Step-by-Step Algorithm

  1. Normalize: strip whitespace, convert to uppercase.
  2. Move the first four characters to the end of the string.
  3. Replace each letter with its numeric equivalent: A=10, B=11 … Z=35.
  4. Compute the integer value of the resulting digit string modulo 97.
  5. The IBAN is valid if the remainder equals 1.

Country Length Table

You need a per-country length table to perform the length check. Here is a minimal version covering the most common IBAN countries:

IBAN_LENGTHS: dict[str, int] = {
    "AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28,
    "BA": 20, "BE": 16, "BG": 22, "BH": 22, "BR": 29,
    "BY": 28, "CH": 21, "CR": 22, "CY": 28, "CZ": 24,
    "DE": 22, "DK": 18, "DO": 28, "EE": 20, "EG": 29,
    "ES": 24, "FI": 18, "FO": 18, "FR": 27, "GB": 22,
    "GE": 22, "GI": 23, "GL": 18, "GR": 27, "GT": 28,
    "HR": 21, "HU": 28, "IE": 22, "IL": 23, "IQ": 23,
    "IS": 26, "IT": 27, "JO": 30, "KW": 30, "KZ": 20,
    "LB": 28, "LC": 32, "LI": 21, "LT": 20, "LU": 20,
    "LV": 21, "LY": 25, "MC": 27, "MD": 24, "ME": 22,
    "MK": 19, "MR": 27, "MT": 31, "MU": 30, "NL": 18,
    "NO": 15, "PK": 24, "PL": 28, "PS": 29, "PT": 25,
    "QA": 29, "RO": 24, "RS": 22, "SA": 24, "SC": 31,
    "SE": 24, "SI": 19, "SK": 24, "SM": 27, "ST": 25,
    "SV": 28, "TL": 23, "TN": 24, "TR": 26, "UA": 29,
    "VA": 22, "VG": 24, "XK": 20,
}

Full Validator Implementation

import re

IBAN_LENGTHS: dict[str, int] = {
    "AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28,
    "BA": 20, "BE": 16, "BG": 22, "BH": 22, "BR": 29,
    "BY": 28, "CH": 21, "CR": 22, "CY": 28, "CZ": 24,
    "DE": 22, "DK": 18, "DO": 28, "EE": 20, "EG": 29,
    "ES": 24, "FI": 18, "FO": 18, "FR": 27, "GB": 22,
    "GE": 22, "GI": 23, "GL": 18, "GR": 27, "GT": 28,
    "HR": 21, "HU": 28, "IE": 22, "IL": 23, "IQ": 23,
    "IS": 26, "IT": 27, "JO": 30, "KW": 30, "KZ": 20,
    "LB": 28, "LC": 32, "LI": 21, "LT": 20, "LU": 20,
    "LV": 21, "LY": 25, "MC": 27, "MD": 24, "ME": 22,
    "MK": 19, "MR": 27, "MT": 31, "MU": 30, "NL": 18,
    "NO": 15, "PK": 24, "PL": 28, "PS": 29, "PT": 25,
    "QA": 29, "RO": 24, "RS": 22, "SA": 24, "SC": 31,
    "SE": 24, "SI": 19, "SK": 24, "SM": 27, "ST": 25,
    "SV": 28, "TL": 23, "TN": 24, "TR": 26, "UA": 29,
    "VA": 22, "VG": 24, "XK": 20,
}


def normalize_iban(raw: str) -> str:
    return raw.replace(" ", "").replace("-", "").upper()


def validate_iban(raw: str) -> tuple[bool, str]:
    """
    Returns (is_valid, error_message).
    error_message is empty string when valid.
    """
    iban = normalize_iban(raw)

    if not re.match(r"^[A-Z]{2}[0-9]{2}[A-Z0-9]+$", iban):
        return False, "Invalid format: must start with country code and two digits"

    country = iban[:2]
    expected_length = IBAN_LENGTHS.get(country)
    if expected_length is None:
        return False, f"Unknown country code: {country}"

    if len(iban) != expected_length:
        return False, f"Wrong length: expected {expected_length}, got {len(iban)}"

    rearranged = iban[4:] + iban[:4]
    numeric = "".join(
        str(ord(c) - ord("A") + 10) if c.isalpha() else c
        for c in rearranged
    )

    if int(numeric) % 97 != 1:
        return False, "Invalid check digits (MOD-97 failed)"

    return True, ""


def format_iban(iban: str) -> str:
    normalized = normalize_iban(iban)
    return " ".join(normalized[i:i+4] for i in range(0, len(normalized), 4))

Edge Cases You Must Handle

Real-world IBAN input arrives in many forms. Your normalizer must handle all of them before any structural check runs:

  • Spaces: DE89 3704 0044 0532 0130 00 — printed format from bank statements. Strip all spaces.
  • Lowercase: de89370400440532013000 — common in API payloads. Convert to uppercase before checking.
  • Dashes: DE89-3704-0044-0532-0130-00 — rare but appears in some ERP exports. Strip dashes.
  • Leading/trailing whitespace: Strip before processing.
  • Non-ASCII characters: Reject — an IBAN contains only A–Z and 0–9.
def normalize_iban(raw: str) -> str:
    return raw.strip().replace(" ", "").replace("-", "").upper()

# All four of these produce the same normalized form:
assert normalize_iban("DE89 3704 0044 0532 0130 00") == "DE89370400440532013000"
assert normalize_iban("de89370400440532013000") == "DE89370400440532013000"
assert normalize_iban("DE89-3704-0044-0532-0130-00") == "DE89370400440532013000"
assert normalize_iban("  DE89370400440532013000  ") == "DE89370400440532013000"

Testing with pytest

A complete test suite covers valid IBANs, structural failures, and edge cases:

import pytest
from iban_validator import validate_iban, normalize_iban

VALID_IBANS = [
    "DE89370400440532013000",
    "GB29NWBK60161331926819",
    "FR7630006000011234567890189",
    "NL91ABNA0417164300",
    "BE68539007547034",
]

INVALID_IBANS = [
    ("DE89370400440532013001", "check digits"),      # last digit wrong
    ("DE8937040044053201300",  "wrong length"),      # 21 chars instead of 22
    ("XX89370400440532013000", "unknown country"),   # XX not in table
    ("",                       "empty string"),
    ("not-an-iban",            "invalid format"),
]


@pytest.mark.parametrize("iban", VALID_IBANS)
def test_valid_ibans(iban):
    valid, error = validate_iban(iban)
    assert valid, f"Expected valid but got: {error}"


@pytest.mark.parametrize("raw,label", INVALID_IBANS)
def test_invalid_ibans(raw, label):
    valid, _ = validate_iban(raw)
    assert not valid, f"Expected invalid for {label}"


def test_normalizes_spaces():
    valid, _ = validate_iban("DE89 3704 0044 0532 0130 00")
    assert valid


def test_normalizes_lowercase():
    valid, _ = validate_iban("de89370400440532013000")
    assert valid


def test_normalizes_dashes():
    valid, _ = validate_iban("DE89-3704-0044-0532-0130-00")
    assert valid

Run with pytest -v iban_test.py. All tests should pass with the implementation above.

Which Approach Should You Use?

Use the ibanchecker.cash API when:

  • You need bank name, BIC, or branch data alongside the validity result.
  • You want automatic updates when new countries join the IBAN standard.
  • You process IBANs in a web service or SaaS application with outbound network access.

Use the manual implementation when:

  • You process IBANs in an air-gapped or offline environment.
  • You have very high volume (millions per day) and latency budgets are tight.
  • You need to embed validation in a mobile app, CLI tool, or data pipeline with no runtime dependencies.

For most production applications, the API is the correct default. The manual implementation is a solid fallback or complement — add it as a local pre-filter before calling the API to avoid paying API costs on obviously malformed input.

Last updated: June 2026

Validate an IBAN instantly

Free IBAN checker — MOD-97 verification, bank lookup, and SEPA status across 84 countries.

Open IBAN Checker →

Related Articles