IBAN Validation in Python — mod-97 Algorithm Explained
IBANs look simple but have 76 country-specific formats and a two-step checksum that catches most transcription errors. Here's how it all fits together.
In this guide
1. What is an IBAN?
An IBAN (International Bank Account Number) is a standardised way to identify a bank account internationally, defined by ISO 13616. Introduced in 1997 to streamline cross-border payments in Europe, IBANs are now used in over 80 countries — and are mandatory for SEPA credit transfers and direct debits in the Eurozone.
Despite the name, IBANs are not just European. Countries like Brazil, Saudi Arabia, Israel, and Pakistan have adopted the standard. When your payment form accepts IBANs from international users, you need a validator that knows the rules for every country.
2. IBAN anatomy — country code, check digits, BBAN
Every IBAN has the same four-component structure:
Country code (2 letters)
ISO 3166-1 alpha-2 country code. Tells you which country's banking system the account belongs to and determines the expected total length and BBAN format.
Check digits (2 digits)
Two numeric digits calculated using the mod-97 algorithm. They allow detection of single-character transpositions and most common transcription errors — the same principle used in LEI codes.
BBAN — Basic Bank Account Number
The domestic account number, whose format is country-specific. A German BBAN encodes the bank code (Bankleitzahl) and account number. A UK BBAN encodes the sort code and account number. The BBAN length and character set vary by country.
| Country | Total length | Example |
|---|---|---|
| Norway | 15 chars | NO93 8601 1117 947 |
| Germany | 22 chars | DE89 3704 0044 0532 0130 00 |
| United Kingdom | 22 chars | GB29 NWBK 6016 1331 9268 19 |
| France | 27 chars | FR76 3000 6000 0112 3456 7890 189 |
| Poland | 28 chars | PL61 1090 1014 0000 0712 1981 2874 |
| Malta | 31 chars | MT84 MALT 0110 0001 2345 MTLC AST0 01S |
3. The mod-97 checksum — step by step
The check digits are validated by rearranging the IBAN and computing the remainder when divided by 97. A valid IBAN always gives a remainder of 1. Let's walk through it with GB82 WEST 1234 5698 7654 32:
Step 1 — Move the first 4 characters to the end
WEST12345698765432GB82
Step 2 — Replace each letter with its numeric value (A=10, B=11, … Z=35)
3214282912345698765432161182
W=32, E=14, S=28, T=29 · G=16, B=11
Step 3 — Compute the remainder modulo 97
In Python, this is trivial — Python handles arbitrarily large integers natively, so you can compute the modulo in one step without chunking.
3214282912345698765432161182 mod 97 = 1 ✓
Here's the algorithm implemented in Python:
# mod97.py — implements the ISO 13616 IBAN checksum def mod97(iban: str) -> int: # Step 1: move first 4 chars to the end rearranged = iban[4:] + iban[:4] # Step 2: replace each letter with its numeric value (A=10 … Z=35) numeric = ''.join( str(ord(c) - 55) if c.isalpha() else c for c in rearranged ) # Step 3: mod 97 — Python handles big ints natively, no chunking needed return int(numeric) % 97 # must be 1 for a valid IBAN iban = "GB82WEST12345698765432" # no spaces print(mod97(iban)) # → 1 ✓
4. Why IBAN validation is harder than it looks
76 different lengths
Every country has a fixed total length for its IBANs, ranging from 15 (Norway) to 34 (Saint Lucia). A German IBAN is always 22 characters; a Polish IBAN is always 28. Passing mod-97 with the wrong length still means the IBAN is invalid.
Country-specific BBAN character sets
Some countries allow alphanumeric BBANs (e.g. UK sort codes include letters like WEST), while others are purely numeric (Germany, France). A BBAN with letters for a purely-numeric country is invalid even if the mod-97 check passes.
Formatting is not part of the standard
IBANs are printed in groups of four characters separated by spaces, but the spaces are for human readability only and must be stripped before validation. Users may also enter dashes or no separators at all.
# All of these represent the same IBAN — normalise first "GB82 WEST 1234 5698 7654 32" # paper/display format "GB82-WEST-1234-5698-7654-32" # dashes "GB82WEST12345698765432" # no separators (canonical)
New countries keep joining
The list of IBAN-participating countries grows over time. Hardcoding a country table in your app risks silently rejecting valid IBANs from newly-added countries. Maintaining that table requires ongoing attention.
5. The production-ready solution
The IsValid IBAN API handles format normalisation, country-length validation, and the mod-97 checksum in a single GET request — for 76 countries. The response also includes the parsed BBAN and an isEU flag for SEPA routing logic.
Get your free API key at isvalid.dev. The free tier includes 100 calls per day — enough for most development and low-volume production use.
Full parameter reference and response schema: IBAN Validation API docs →
6. Python code example
Using the requests library — the de facto standard for HTTP in Python. Install it with pip install requests.
# iban_validator.py import os import requests API_KEY = os.environ["ISVALID_API_KEY"] BASE_URL = "https://api.isvalid.dev" def validate_iban(iban: str) -> dict: """Validate an IBAN using the IsValid API. Args: iban: IBAN in any format (spaces, dashes, or none). Returns: Validation result as a dictionary. Raises: requests.HTTPError: If the API returns a non-2xx status. """ response = requests.get( f"{BASE_URL}/v0/iban", params={"value": iban}, headers={"Authorization": f"Bearer {API_KEY}"}, ) response.raise_for_status() return response.json() # ── Example usage ──────────────────────────────────────────────────────────── result = validate_iban("GB82 WEST 1234 5698 7654 32") if not result["valid"]: print("Invalid IBAN") else: print(f"Valid IBAN — {result['countryName']} ({'SEPA' if result['isEU'] else 'non-SEPA'})") print(f"BBAN: {result['bban']}") print(f"Formatted: {result['formatted']}") if result.get("bankName"): print(f"Bank: {result['bankName']}") if result.get("bankBic"): print(f"BIC: {result['bankBic']}") # → Valid IBAN — United Kingdom (non-SEPA) # → BBAN: WEST12345698765432 # → Formatted: GB82 WEST 1234 5698 7654 32 # → Bank: (name from registry, if available)
In a payment form handler, you might use it like this with Flask:
# app.py (Flask) from flask import Flask, request, jsonify app = Flask(__name__) @app.post("/payment") def payment(): data = request.get_json() try: iban_check = validate_iban(data["iban"]) except requests.RequestException: return jsonify(error="IBAN validation service unavailable"), 502 if not iban_check["valid"]: return jsonify(error="Invalid IBAN"), 400 # Use isEU to route SEPA vs international wire transfer_type = "sepa" if iban_check["isEU"] else "international" initiate_transfer( iban=data["iban"], transfer_type=transfer_type, amount=data["amount"], ) return jsonify(success=True, transferType=transfer_type)
isEU flag to automatically choose between a SEPA transfer (cheaper, 1 business day) and an international wire for non-Eurozone IBANs. The formatted field gives you the print-friendly grouped format to display back to the user.7. cURL example
Validate a German IBAN:
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/iban?value=DE89370400440532013000"
Spaces are handled automatically:
curl -G -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "value=GB82 WEST 1234 5698 7654 32" \ "https://api.isvalid.dev/v0/iban"
With explicit country code (when the IBAN has no prefix):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/iban?value=370400440532013000&countryCode=DE"
8. Understanding the response
Valid German IBAN:
{ "valid": true, "countryCode": "DE", "countryName": "Germany", "bban": "370400440532013000", "isEU": true, "formatted": "DE89 3704 0044 0532 0130 00", "bankCode": "37040044", "bankName": "Commerzbank", "bankBic": "COBADEFFXXX" }
Invalid IBAN (bad checksum):
{ "valid": false }
| Field | Type | Description |
|---|---|---|
| valid | boolean | Format, length, and mod-97 checksum all pass |
| countryCode | string | 2-letter ISO 3166-1 country code from the IBAN prefix |
| countryName | string | Full country name |
| bban | string | The domestic account number portion (after the 4-char IBAN header) |
| isEU | boolean | Whether the country is an EU member state (SEPA eligible) |
| formatted | string | Human-readable IBAN grouped in blocks of 4 characters |
| bankCode | string | null | National bank code extracted from the BBAN (e.g. 8-digit Bankleitzahl for DE, 8-digit numer rozliczeniowy for PL) |
| bankName | string | null | Name of the bank identified by the bank code, from official central bank registries |
| bankBic | string | null | BIC/SWIFT code of the bank if available from the national registry |
9. Edge cases to handle
SEPA vs. non-SEPA routing
Not all IBAN countries participate in SEPA. Norway, Iceland, Liechtenstein, and Switzerland have IBANs but are not EU members — though they are part of SEPA through EEA/bilateral agreements. Use the isEU flag as a starting point, but apply your own SEPA country list for precise routing.
Accounts without IBANs
The US, Canada, Australia, China, and many other countries do not use IBANs. When accepting international payments, always provide a fallback for account number + routing number / sort code / BSB input for non-IBAN countries.
def show_correct_account_form(country: str) -> None: if country in IBAN_COUNTRIES: show_iban_input() else: show_routing_and_account_input(country) # ABA, sort code, BSB, etc.
Input normalisation
Users paste IBANs from documents or emails in many formats. The API strips spaces and hyphens and uppercases the input automatically. Pass the raw user input — no need to pre-process it. The formatted field in the response gives you a clean display version to show back to the user.
Storing IBANs
Store the normalised form (no spaces, uppercase) in your database — e.g. GB82WEST12345698765432. Generate the display format on the fly from the formatted field when needed. This avoids inconsistencies from different input formats.
Summary
See also
Validate IBANs instantly
Free tier includes 100 API calls per day. No credit card required. Supports 76 countries including all EU member states and major non-EU IBAN countries.