ibanchecker.cash
Developer ResourcesJune 13, 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.

Emre Arkan
Emre Arkan

Technical Support, ibanchecker.cash

pythoniban-validationmod-97apicode
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.

Sources & References

Last updated: June 2026

Integrate IBAN validation via API

REST API with 1,000 free validations/month. JSON response with bank name, SEPA status, and BBAN breakdown.