Skip to content
faker.tools
All 79

Guide · 12 min read

Checksums Your Test Data Has to Satisfy

Luhn, mod-97, ABA, GTIN and the VIN transliteration — how the five check digits you meet most often actually work, and why faking them wastes your fixture.

A fixture full of random digits is not test data. It is a way of testing your validator's rejection path a thousand times.

The moment a card number reaches if (!luhn(pan)) return error, a random sixteen-digit string stops being useful. It never gets to the tokenisation code, the BIN lookup, the currency inference, the fraud heuristic, or the storage layer — all the parts you actually wanted to exercise. You have written an elaborate test of one if statement.

So the first property real test data needs is that it survives the front door. That means computing the check digit rather than inventing it. Here is how the five you will meet most often actually work.

Why check digits exist at all

Every one of these algorithms was designed for a world of human transcription and unreliable channels — clerks reading numbers over the phone, punch cards, magnetic stripes. They are not cryptographic and they are not trying to be. They catch mistakes, not attacks.

Specifically, a well-designed check digit catches:

  • Single-digit errors — one digit wrong. All of these catch 100% of these.
  • Adjacent transpositions45 typed as 54. This is the second most common human error and it is why the algorithms weight digits by position rather than just summing them. A plain sum catches no transpositions at all.

What they do not catch is anyone deliberately constructing a valid number, which takes about four lines of code. A check digit tells you a number was probably typed correctly. It tells you nothing about whether it was issued.

Luhn: cards, IMEIs, and a great deal else

The Luhn algorithm (ISO/IEC 7812-1) was patented by Hans Peter Luhn at IBM in 1960. It validates payment card numbers, IMEI device identifiers, some national IDs, and NPI numbers in US healthcare.

The procedure, working from the rightmost digit:

  1. Double every second digit — that is, the ones in even positions counting from the right, starting at position 2.
  2. If a doubled value exceeds 9, subtract 9. (Equivalently, add its two digits: 141 + 45.)
  3. Sum everything.
  4. The number is valid if the total is divisible by 10.

Worked through on 4539 1488 0343 6467, from the right:

7  6  4  6  3  4  3  0  8  8  4  1  9  3  5  4   digits (right to left)
7 12  4 12  3  8  3  0  8 16  4  2  9  6  5  8   every second doubled
7  3  4  3  3  8  3  0  8  7  4  2  9  6  5  8   subtract 9 where > 9
                                       sum = 80

80 mod 10 is 0, so the number passes.

To generate rather than validate, set the last digit to zero, run the sum, and the check digit is (10 - sum % 10) % 10.

function luhnCheckDigit(withoutCheck) {
  let sum = 0;
  let double = true; // the check digit sits at position 1, so start doubling at 2
  for (let i = withoutCheck.length - 1; i >= 0; i--) {
    let d = withoutCheck.charCodeAt(i) - 48;
    if (double) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    double = !double;
  }
  return (10 - (sum % 10)) % 10;
}

The weakness worth knowing: Luhn catches every single-digit error and every adjacent transposition except 0990. Doubling 0 gives 0 and doubling 9 gives 189, so both orderings produce the same contribution. It is a small hole, and it has been in every card number since 1960.

A generation subtlety: the first six to eight digits of a card number are the IIN, which identifies the issuer. A Luhn-valid number under a nonexistent IIN will pass your client-side validator and then fail differently — often more confusingly — further down the stack. If you are testing BIN routing, the prefix matters as much as the checksum.

Mod-97: IBANs

ISO 13616 uses a much stronger scheme, and unusually the check digits sit at the front rather than the back — positions 3 and 4.

To validate:

  1. Move the first four characters to the end. GB82 WEST 1234 5698 7654 32 becomes WEST12345698765432GB82.
  2. Replace every letter with a two-digit number: A = 10, B = 11, … Z = 35.
  3. Interpret the result as one enormous integer and take it modulo 97.
  4. Valid if and only if the remainder is 1.

The integer is far too large for a 64-bit type — a 34-character IBAN becomes a 60-digit number — so implementations process it in chunks:

function mod97(iban) {
  const rearranged = iban.slice(4) + iban.slice(0, 4);
  const expanded = rearranged.replace(/[A-Z]/g, (c) => c.charCodeAt(0) - 55);
  let remainder = 0;
  for (const ch of expanded) {
    remainder = (remainder * 10 + Number(ch)) % 97;
  }
  return remainder;
}

To generate check digits, put 00 in positions 3–4, run the same rearrangement, and the check digits are 98 - (remainder mod 97), zero-padded to two characters.

Mod-97 is genuinely strong. It catches all single-digit errors, all transpositions including the 09/90 case Luhn misses, and roughly 98.9% of all other errors — the residual being the 1-in-97 chance that a random corruption lands on the same remainder.

What it does not tell you: an IBAN's country-specific length and BBAN structure are separate rules. Belgium is 16 characters, France 27, Malta 31. A mod-97-valid string of the wrong length for its country code is invalid, and a good validator checks both. There is also no reserved IBAN block anywhere in the world, which is why a generated IBAN should never be pointed at a payment rail.

ABA 3-7-1: US routing numbers

Nine digits, weighted 3, 7, 1 repeating, and the whole weighted sum must be divisible by 10:

3(d₁ + d₄ + d₇) + 7(d₂ + d₅ + d₈) + 1(d₃ + d₆ + d₉) ≡ 0 (mod 10)

On 021000021:

(3×0 + 7×2 + 1×1) + (3×0 + 7×0 + 1×0) + (3×0 + 7×2 + 1×1)
= (0 + 14 + 1) + 0 + (0 + 14 + 1) = 30

30 mod 10 is 0. Valid.

The 3, 7, 1 weights are not arbitrary: they are chosen so that adjacent digits always carry different weights, which is what makes transpositions detectable. Note that this is a weak check — one digit in ten passes by chance — and it is doing very little work. The first two digits also encode a Federal Reserve district, so 8089 and several other prefixes are invalid regardless of what the checksum says.

GTIN: barcodes, EAN-13, UPC, ISBN-13

Alternating weights of 1 and 3, applied from the right, with the check digit itself weighted 1. This covers UPC-A (12 digits), EAN-13, ISBN-13, and GTIN-14 — they are the same algorithm at different lengths.

For EAN-13, the twelve digits before the check take weights 1, 3, 1, 3, … reading left to right, and the check digit is whatever brings the total to a multiple of 10:

function gtinCheckDigit(digits) {
  let sum = 0;
  // Weights alternate 3,1 from the right-hand end of the payload.
  for (let i = digits.length - 1, w = 3; i >= 0; i--, w = w === 3 ? 1 : 3) {
    sum += (digits.charCodeAt(i) - 48) * w;
  }
  return (10 - (sum % 10)) % 10;
}

The weighting is what distinguishes it from a plain digit sum, and again it is transposition detection that motivates the design. Because the weights are 1 and 3 rather than 1 and 2, GTIN does not have Luhn's 09/90 blind spot — but it acquires a different one, missing transpositions where the digits differ by 5.

ISBN-10, the older scheme, is entirely different: weights 10 down to 1, modulo 11, with X standing in for a check value of 10. That X is the reason ISBN parsing code so often has a special case.

VIN: the transliteration table

Vehicle Identification Numbers are seventeen characters, and the check digit in position 9 is the fiddliest of the five — partly because it is often misattributed.

ISO 3779 defines the structure of a VIN. The position-9 check digit is a North American requirement, imposed by 49 CFR 565 in the United States and mirrored in Canada. A VIN issued for a European or Japanese domestic vehicle is a perfectly valid VIN that may have anything in position 9. Validators that reject on a failed check digit will reject real cars.

Where it does apply, three steps:

1. Transliterate letters to numbers. The alphabet excludes I, O and Q entirely, so they cannot be confused with 1 and 0.

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

2. Apply positional weights — note the 0 at position 9, which is the check digit's own slot:

8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2

3. Sum, take modulo 11. The result is the check digit — except that a remainder of 10 is written as X.

That X matters: roughly one VIN in eleven has a letter in a field that is otherwise numeric, and code that types position 9 as an integer will break on about 9% of real input.

What all of this buys you

A checksum-correct fixture gets past the guard clause. That is the whole point, and it is worth restating what it does not buy:

  • It is not authenticity. A Luhn-valid card number is not an account. A mod-97-valid IBAN is not a bank relationship. The check digit says the number is well-formed, and issuance is an entirely separate question answered by a registry you do not have.
  • It is not a substitute for range discipline. Because these numbers pass validators, they are exactly the ones that cause damage if they escape into a real system. Structural validity and reserved ranges are complementary: the checksum makes a fixture useful, the reserved range makes it safe.
  • It is not security. Every algorithm here is public, deterministic, and invertible in a few lines. Treating a check digit as evidence of anything beyond careful typing is a category error.

The reason to compute them properly is simple: an invalid number tests the first line of your validator, and a valid one tests everything after it. Only one of those is the code you were worried about.

Tools this guide covers

Full directory →