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.
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:
- Format check: The string must start with a valid two-letter country code and consist of alphanumeric characters only.
- 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.
- 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
- Normalize: strip whitespace, convert to uppercase.
- Move the first four characters to the end of the string.
- Replace each letter with its numeric equivalent: A=10, B=11 … Z=35.
- Compute the integer value of the resulting digit string modulo 97.
- 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 validRun 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