Check Digit Algorithms Explained — Luhn, MOD-97, MOD-11 and More
Every major identifier — from credit cards to IBANs to vehicle identification numbers — includes a check digit computed by a specific algorithm. This guide covers all the algorithms you'll encounter in the real world, with formulas, worked examples, and practical code.
In this guide
1. Why check digits exist
When a human types an identifier — a credit card number, an IBAN, a tax ID — there's roughly a 1 in 10 chance of mistyping a single digit. Transposing two adjacent digits is even more common. A check digit is an extra digit (or character) computed from the rest of the identifier using a mathematical formula.
Before sending the number to a remote system, you recompute the check digit and compare it with the one provided. If they don't match, the number is wrong — guaranteed. This saves a network round-trip and prevents costly errors in banking, finance, and logistics.
Different algorithms offer different detection capabilities. The simplest (sum mod 10) catches all single-digit errors. More sophisticated algorithms (like Luhn, MOD-97, or Verhoeff) also catch transposition errors and other common mistake patterns. The choice of algorithm depends on the length of the identifier and the acceptable error rate.
2. Luhn algorithm (mod-10)
Invented by IBM scientist Hans Peter Luhn in 1954, this is the most widely used check digit algorithm in the world. It's used for credit card numbers, ISIN codes, and IMEI numbers.
How it works
Step 1. Starting from the rightmost digit (the check digit) and moving left, double every second digit.
Step 2. If doubling produces a number greater than 9, subtract 9 (equivalent to summing the two digits: 14 → 1+4 = 5, or 14−9 = 5).
Step 3. Sum all digits (both doubled and undoubled).
Step 4. If sum % 10 === 0, the number is valid.
Worked example — Visa 4539 1488 0343 6467
| Step | 4 | 5 | 3 | 9 | 1 | 4 | 8 | 8 | 0 | 3 | 4 | 3 | 6 | 4 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Double? | x2 | - | x2 | - | x2 | - | x2 | - | x2 | - | x2 | - | x2 | - | x2 | - |
| After | 8 | 5 | 6 | 9 | 2 | 4 | 7 | 8 | 0 | 3 | 8 | 3 | 3 | 4 | 3 | 7 |
Sum: 8+5+6+9+2+4+7+8+0+3+8+3+3+4+3+7 = 80. Since 80 % 10 = 0, the number is valid.
For alphanumeric identifiers (ISIN)
ISIN codes contain letters. Before applying Luhn, each letter is replaced with its numeric value: A=10, B=11, ..., Z=35. The resulting digit string is then processed with the standard Luhn algorithm. For example, ISIN US0378331005 becomes 3028037833100 + check digit 5.
// Luhn algorithm — works for credit cards, IMEI, ISIN (after letter→digit conversion) function luhnCheck(digits: string): boolean { let sum = 0; let shouldDouble = false; for (let i = digits.length - 1; i >= 0; i--) { let d = parseInt(digits[i], 10); if (shouldDouble) { d *= 2; if (d > 9) d -= 9; } sum += d; shouldDouble = !shouldDouble; } return sum % 10 === 0; }
Where it's used
| Identifier | Format | Check digit position |
|---|---|---|
| Credit Card | 13–19 digits | Last digit |
| ISIN | 2 letters + 9 alnum + 1 digit | Last digit (after letter→digit conversion) |
| IMEI | 15 digits | Last digit |
3. MOD-97 algorithm (ISO 7064)
MOD-97 is used by IBAN (International Bank Account Number) and LEI (Legal Entity Identifier). It provides two check digits instead of one, which gives it much stronger error detection — it catches 99.97% of all errors, including all single-digit errors, all transpositions, and most jump transpositions.
How it works (IBAN example)
Step 1. Move the first 4 characters (country code + 2 check digits) to the end.
GB29 NWBK 6016 1331 9268 19 → NWBK 6016 1331 9268 19 GB29
Step 2. Replace each letter with its numeric value: A=10, B=11, ..., Z=35.
N=23, W=32, B=11, K=20 → 2332112060161331926819161129
Step 3. Compute this (very large) number modulo 97. Process in chunks of 9 digits to avoid integer overflow.
Step 4. If the remainder is 1, the IBAN is valid.
Chunked modulo computation
The numeric string can be 30+ digits — far beyond what a 64-bit integer can hold. The standard technique is to process the string in chunks:
// MOD-97 check — used for IBAN and LEI validation function mod97(digitString: string): number { let remainder = 0; for (let i = 0; i < digitString.length; i++) { remainder = (remainder * 10 + parseInt(digitString[i], 10)) % 97; } return remainder; } // For IBAN: rearrange, convert letters, then check mod97 === 1 function validateIBAN(iban: string): boolean { const cleaned = iban.replace(/\s/g, '').toUpperCase(); // Move first 4 chars to end const rearranged = cleaned.slice(4) + cleaned.slice(0, 4); // Replace letters with numbers (A=10, B=11, ..., Z=35) const digits = rearranged.replace(/[A-Z]/g, (ch) => String(ch.charCodeAt(0) - 55) ); return mod97(digits) === 1; }
LEI — same algorithm, different format
The LEI (ISO 17442) is a 20-character identifier: 4 digits (LOU prefix) + 14 alphanumeric characters + 2 check digits. The check digit calculation is identical to IBAN — move the first 4 characters to the end, convert letters to numbers, and verify that mod-97 equals 1.
4. Weighted MOD-11
Several identifiers use a weighted sum with modulo 11. Each digit is multiplied by a weight that depends on its position. The result is taken mod 11, and if the remainder is 10, the check character is 'X' (representing 10 in a single character).
ISBN-10
The classic 10-digit ISBN uses descending weights 10, 9, 8, ..., 2 for the first 9 digits. The check digit makes the weighted sum divisible by 11.
Example: ISBN-10 0-306-40615-2
| Digit | 0 | 3 | 0 | 6 | 4 | 0 | 6 | 1 | 5 | 2 |
|---|---|---|---|---|---|---|---|---|---|---|
| Weight | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
| Product | 0 | 27 | 0 | 42 | 24 | 0 | 24 | 3 | 10 | 2 |
Sum: 0+27+0+42+24+0+24+3+10+2 = 132. Since 132 % 11 = 0, the ISBN is valid.
// ISBN-10 check digit validation function validateISBN10(isbn: string): boolean { const digits = isbn.replace(/[\s-]/g, ''); if (digits.length !== 10) return false; let sum = 0; for (let i = 0; i < 10; i++) { const ch = digits[i]; const val = ch === 'X' || ch === 'x' ? 10 : parseInt(ch, 10); sum += val * (10 - i); } return sum % 11 === 0; }
ISSN
The 8-digit ISSN uses weights 8, 7, 6, 5, 4, 3, 2, 1. Same principle — weighted sum mod 11 must equal 0. The last character can be 'X' (= 10).
// ISSN check digit validation function validateISSN(issn: string): boolean { const digits = issn.replace(/[\s-]/g, ''); if (digits.length !== 8) return false; let sum = 0; for (let i = 0; i < 8; i++) { const ch = digits[i]; const val = ch === 'X' || ch === 'x' ? 10 : parseInt(ch, 10); sum += val * (8 - i); } return sum % 11 === 0; }
REGON (Poland)
The Polish REGON uses mod-11 with non-sequential weights. The 9-digit version uses weights [8, 9, 2, 3, 4, 5, 6, 7]. The 14-digit version first validates the 9-digit prefix, then applies a second set of weights [2, 4, 8, 5, 0, 9, 7, 3, 6, 1, 2, 4, 8] to all 13 non-check digits. If the remainder is 10, the check digit is 0.
// REGON-9 check digit validation function validateREGON9(regon: string): boolean { if (regon.length !== 9) return false; const weights = [8, 9, 2, 3, 4, 5, 6, 7]; let sum = 0; for (let i = 0; i < 8; i++) { sum += parseInt(regon[i], 10) * weights[i]; } let check = sum % 11; if (check === 10) check = 0; return check === parseInt(regon[8], 10); }
5. Weighted MOD-10
Several identifiers use a weighted sum with modulo 10 — simpler than mod-11 (no 'X' needed) but still effective at catching common errors. Different identifiers use different weight sequences.
ISBN-13 / EAN-13 (alternating 1, 3)
ISBN-13 and EAN-13 use the same algorithm: multiply each of the first 12 digits by alternating weights of 1 and 3, sum the results, and the check digit is (10 - sum % 10) % 10.
Example: EAN-13 4006381333931
| Digit | 4 | 0 | 0 | 6 | 3 | 8 | 1 | 3 | 3 | 3 | 9 | 3 | 1 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Weight | 1 | 3 | 1 | 3 | 1 | 3 | 1 | 3 | 1 | 3 | 1 | 3 | - |
| Product | 4 | 0 | 0 | 18 | 3 | 24 | 1 | 9 | 3 | 9 | 9 | 9 | - |
Sum: 4+0+0+18+3+24+1+9+3+9+9+9 = 89. Check: (10 − 89 % 10) % 10 = 1. Matches the last digit.
// EAN-13 / ISBN-13 check digit validation function validateEAN13(code: string): boolean { if (code.length !== 13) return false; let sum = 0; for (let i = 0; i < 12; i++) { sum += parseInt(code[i], 10) * (i % 2 === 0 ? 1 : 3); } const check = (10 - (sum % 10)) % 10; return check === parseInt(code[12], 10); }
EAN-8 (alternating 3, 1)
Note the reversed starting weight: EAN-8 uses 3, 1, 3, 1, 3, 1, 3 for the first 7 digits — the opposite order from EAN-13. This is a common source of bugs when implementing both formats.
ABA Routing Number (repeating 3, 7, 1)
US bank routing numbers use the repeating weight sequence 3, 7, 1 for all 9 digits. The weighted sum mod 10 must equal 0.
// ABA Routing Number validation function validateABA(routing: string): boolean { if (routing.length !== 9) return false; const weights = [3, 7, 1, 3, 7, 1, 3, 7, 1]; let sum = 0; for (let i = 0; i < 9; i++) { sum += parseInt(routing[i], 10) * weights[i]; } return sum % 10 === 0; }
PESEL (Poland — weights 1, 3, 7, 9, ...)
The Polish national ID number uses the weight pattern [1, 3, 7, 9] repeated, plus a final weight of 1 for the check digit itself. The weighted sum of all 11 digits mod 10 must equal 0.
// PESEL validation function validatePESEL(pesel: string): boolean { if (pesel.length !== 11) return false; const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3, 1]; let sum = 0; for (let i = 0; i < 11; i++) { sum += parseInt(pesel[i], 10) * weights[i]; } return sum % 10 === 0; }
6. VIN check digit (MOD-11 with transliteration)
Vehicle Identification Numbers use a unique combination of character transliteration and weighted mod-11 checksumming. The check digit sits at position 9 (the 9th character) and can be 0–9 or X.
Transliteration table
Each letter is converted to a numeric value. Note that I, O, Q are never used in VINs (they look like 1, 0, and 0/9).
| Letter | A | B | C | D | E | F | G | H | J | K | L | M | N | P | R | S | T | U | V | W | X | Y | Z |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Value | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | 2 | 3 | 4 | 5 | 7 | 9 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
Position weights
Each of the 17 positions has a fixed weight. Position 9 (the check digit itself) has weight 0 — it doesn't contribute to the sum:
// VIN check digit validation (mandatory for North American vehicles) const VIN_TRANSLITERATION: Record<string, number> = { A:1, B:2, C:3, D:4, E:5, F:6, G:7, H:8, J:1, K:2, L:3, M:4, N:5, P:7, R:9, S:2, T:3, U:4, V:5, W:6, X:7, Y:8, Z:9, }; const VIN_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; function validateVINCheckDigit(vin: string): boolean { let sum = 0; for (let i = 0; i < 17; i++) { const ch = vin[i]; const val = /\d/.test(ch) ? parseInt(ch, 10) : VIN_TRANSLITERATION[ch]; sum += val * VIN_WEIGHTS[i]; } const remainder = sum % 11; const expected = remainder === 10 ? 'X' : String(remainder); return vin[8] === expected; }
7. CUSIP check digit
CUSIP (Committee on Uniform Securities Identification Procedures) is a 9-character identifier for North American securities. Characters 1–8 are the payload; character 9 is the check digit. The algorithm is a variant of Luhn that handles alphanumeric input.
Step 1. Convert each character to a numeric value: digits 0–9 map to themselves, letters A–Z map to 10–35, and special characters *=36, @=37, #=38.
Step 2. For characters at odd positions (1-indexed: 2, 4, 6, 8), multiply the value by 2.
Step 3. For each product, sum both digits (e.g., 18 → 1+8 = 9). This is equivalent to Math.floor(v/10) + v%10.
Step 4. Sum all digit sums. Check digit = (10 - sum % 10) % 10.
// CUSIP check digit validation function validateCUSIP(cusip: string): boolean { if (cusip.length !== 9) return false; let sum = 0; for (let i = 0; i < 8; i++) { const ch = cusip[i]; let val: number; if (/\d/.test(ch)) val = parseInt(ch, 10); else if (/[A-Z]/.test(ch)) val = ch.charCodeAt(0) - 55; // A=10 else if (ch === '*') val = 36; else if (ch === '@') val = 37; else if (ch === '#') val = 38; else return false; if (i % 2 === 1) val *= 2; // odd positions (0-indexed) sum += Math.floor(val / 10) + (val % 10); } const check = (10 - (sum % 10)) % 10; return check === parseInt(cusip[8], 10); }
8. VAT number checksums
VAT identification numbers are where check digit algorithms get truly diverse. Each EU member state (and many non-EU countries) uses its own algorithm — some simple, some remarkably complex.
Germany — iterative MOD 11-10
German VAT uses an unusual iterative algorithm: start with a product of 10, then for each digit add it to the product, take mod 10 (with 0 → 10), double, and take mod 11. The final check digit makes the last product equal 1.
Czech Republic — weighted MOD 11
Uses weights [8, 7, 6, 5, 4, 3, 2] or [6, 5, 4, 3, 2, 1, 6, 5] depending on whether it's a legal entity (8 digits) or natural person (9–10 digits). Legal entity check digit is computed as (11 - sum % 11) % 10.
Brazil — CNPJ double weighted MOD 11
The 14-digit CNPJ uses two separate weighted MOD-11 calculations with different weight sequences. The first 12 digits produce check digit 1 (using weights 5,4,3,2,9,8,7,6,5,4,3,2), then all 13 digits produce check digit 2 (using weights 6,5,4,3,2,9,8,7,6,5,4,3,2). If the remainder is less than 2, the check digit is 0; otherwise it's 11 minus the remainder.
China — USCC weighted MOD 31
The Chinese Unified Social Credit Code uses modulo 31 with weights that are successive powers of 3 mod 31: [1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28]. The check digit is a character from a 31-symbol alphabet (0–9 plus selected uppercase letters).
9. Comparison table
All algorithms at a glance — grouped by base modulus.
| Identifier | Algorithm | Modulus | Check digits | Format |
|---|---|---|---|---|
| Credit Card | Luhn | 10 | 1 (last) | 13–19 digits |
| ISIN | Luhn (alphanumeric) | 10 | 1 (last) | 12 chars |
| IMEI | Luhn | 10 | 1 (last) | 15 digits |
| IBAN | MOD-97 | 97 | 2 (pos 3–4) | 15–34 chars |
| LEI | MOD-97 | 97 | 2 (last) | 20 chars |
| ISBN-10 | Weighted sum | 11 | 1 (last, 0–9/X) | 10 chars |
| ISSN | Weighted sum | 11 | 1 (last, 0–9/X) | 8 chars |
| ISBN-13 | Weighted 1,3 | 10 | 1 (last) | 13 digits |
| EAN-13 | Weighted 1,3 | 10 | 1 (last) | 13 digits |
| EAN-8 | Weighted 3,1 | 10 | 1 (last) | 8 digits |
| CUSIP | Luhn variant | 10 | 1 (last) | 9 chars |
| VIN | Weighted + transliteration | 11 | 1 (pos 9, 0–9/X) | 17 chars |
| ABA Routing | Weighted 3,7,1 | 10 | 1 (last) | 9 digits |
| PESEL | Weighted 1,3,7,9 | 10 | 1 (last) | 11 digits |
| REGON | Weighted | 11 | 1 (last) | 9 or 14 digits |
| VAT | Country-specific | varies | varies | varies |
10. What can check digits catch?
Not all algorithms are equal. Here's what each class of algorithm can detect:
| Error type | Simple mod-10 | Luhn | MOD-11 | MOD-97 |
|---|---|---|---|---|
| Single digit substitution | 90% | 100% | 100% | 100% |
| Adjacent transposition (ab → ba) | ~89% | ~98% | 100% | 100% |
| Jump transposition (abc → cba) | ~89% | ~95% | ~91% | ~99.97% |
| Twin error (aa → bb) | 90% | 100% | 100% | 100% |
| Random error | 90% | ~90% | ~91% | ~99.97% |
Single digit error
Changing any one digit to a different digit. Example: 4111111111111111 → 4111111111111211. All algorithms in this article catch 100% of single-digit errors (except simple mod-10 which catches 90%).
Transposition error
Swapping two adjacent digits. Example: 4111111111111111 → 4111111111111111 (not visible for equal digits, but 4539 → 4593 would be caught). Luhn catches nearly all of these because the doubling pattern makes the contribution of each position asymmetric.
Summary
Choosing an algorithm
If you're designing a new identifier format, consider: Luhn is the standard for numeric identifiers that humans type (cards, phone IMEIs). MOD-97 is the best choice when errors are costly and you can afford two check characters. Weighted MOD-11 with 'X' is a classic for book/serial identifiers where a single extra character is acceptable.
See also
Validate any identifier instantly
Free tier includes 100 API calls per day. All check digit algorithms are built in — no need to implement them yourself.