The Luhn algorithm (also called the “mod 10” or “modulus 10” check) validates a number by doubling every second digit counting from the right, adding up all the resulting digits, and accepting the number only if the total ends in 0. Payment card numbers from the major card brands carry a Luhn check digit as their last digit, and so do mobile-phone IMEIs, Canadian Social Insurance Numbers and US National Provider Identifiers.
The check exists to catch typing and copying mistakes. This guide works through the arithmetic on a number built for the purpose, shows the difference between computing a check digit and validating one, proves by brute-force testing exactly which mistakes the check catches and which it misses, and ends with tested JavaScript (Node.js) and Python code.
What a Luhn pass does not mean. A number that passes the check is well-formed, nothing more. It does not show that a card or account exists, that it is open, that it belongs to the person typing it, or that it can pay. Anyone can make a passing number by appending the right last digit, so the check gives no security and no fraud protection. Use it to catch typos before you send the number on, and leave the real answer to the payment authorization.
Scope: this page covers the check-digit arithmetic only. Card numbering itself (issuer prefixes and number lengths) is defined in ISO/IEC 7812-1, a paid standard that is not reproduced here. Checking a brand’s prefix and length is a separate step.
Where the algorithm comes from
Hans P. Luhn filed a patent application on 6 January 1954 for a “Computer for Verifying Numbers”, assigned to International Business Machines Corporation (IBM). It was granted as US patent 2,950,048 on 23 August 1960. The invention was a small mechanical device with slots and belts, not software. Its stated purpose was to show “whether, in transmitting a number, an error has been made, such as a transposition of the digits”, with part numbers that are “ordered, manufactured, invoiced, shipped, and billed” as the example use.
The patent already contains every part of today’s algorithm, in older wording:
- alternate digits are swapped for a substitute digit, which “equals twice the original digit plus an end around carry”. In other words, 6 becomes 12, and 1 + 2 gives 3;
- the digits are added up with “casting out tens”, so only the last digit of the running total is kept;
- the check digit is whatever brings the total to zero, and it goes on the right-hand end;
- whether you start substituting on the first digit or the second depends on whether the number has an odd or even number of digits. That way the check digit itself is never substituted. Counting from the right, as programmers do today, gives the same result.
The patent’s own worked example takes the seven-digit number 4872148, gets 4 as its check digit, and produces the verified number 48721484. The test suite further down uses it as its first test vector. Later standards reuse the same rule. The IMEI specification (3GPP TS 23.003) says its “Luhn Check Digit” is computed by the method defined in ISO/IEC 7812. The US health-identifier rules call it the “modulus 10 ‘double-add-double’ check digit” and note that it “is recognized as an ISO standard”.
The rule in three steps
Number the digits from the right, starting at 1 for the rightmost digit.
- Double every digit in an even position (2nd, 4th, 6th… from the right). When a doubled value is 10 or more, add its two digits together, which is the same as subtracting 9.
- Add everything: the doubled-and-reduced digits plus the digits you left alone.
- Check the last digit of the total. If it is 0, the number is valid.
Each digit becomes one of the following when doubled. The table below is all there is to the “add the digits of the product” step:
| Digit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| Doubled, digits added | 0 | 2 | 4 | 6 | 8 | 1 (10) | 3 (12) | 5 (14) | 7 (16) | 9 (18) |
The bottom row contains each of 0–9 exactly once. Most of the error-detection results below follow from that one fact.
Computing a check digit vs. validating a number
Both jobs use the same arithmetic. They differ only in where the counting starts:
- Validating a complete number: the check digit is position 1 and is not doubled. Run the three steps on the whole number and look for a total ending in 0.
- Computing a check digit for a number that doesn’t have one yet: imagine the missing check digit in position 1, so the rightmost digit you actually have is in position 2 and is doubled. Add up as usual, then the check digit is
(10 − total mod 10) mod 10. That is the amount needed to reach the next multiple of 10, or 0 if the total already ends in 0.
The most common bug is to compute a check digit by running the validation loop on the partial number. That doubles the wrong digits. The code below avoids it by computing over partial + "0", which puts a placeholder in the check position.
Worked example on a synthetic number
The number below was made up for this page. It is not a card number. We need the check digit for the eleven digits 7309 5184 266.
| Position from right | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Digit | 7 | 3 | 0 | 9 | 5 | 1 | 8 | 4 | 2 | 6 | 6 | ? |
| Doubled? | yes | – | yes | – | yes | – | yes | – | yes | – | yes | – |
| Counts as | 5 (14) | 3 | 0 | 9 | 1 (10) | 1 | 7 (16) | 4 | 4 | 6 | 3 (12) | ? |
Total: 5 + 3 + 0 + 9 + 1 + 1 + 7 + 4 + 4 + 6 + 3 = 43. The total ends in 3, so the check digit is 10 − 3 = 7, and the complete number is 7309 5184 2667.
Validating it: run the same table with 7 in position 1. The total becomes 43 + 7 = 50, which ends in 0, so the number is valid.
Three typing errors on the same number:
- Last digit typed as 1 (7309 5184 2661): the total is 44 and the number is rejected.
- The 5 and the 1 swapped (7309 1584 2667): the 1 is now doubled (counts 2) and the 5 is not (counts 5). Those two positions used to contribute 1 + 1 = 2 and now contribute 7, so the total is 55 and the number is rejected.
- The 0 and the 9 swapped (7390 5184 2667): the doubled 9 counts as 9 and the plain 0 counts as 0, which is the same 9 the two positions gave before. The total is still 50, so the number passes even though it is wrong. The next section explains why this pair is special.
Which errors the check catches, proved by testing
Luhn’s patent says transposition errors “will be detected”. That is true for nearly all of them, but not all. We didn’t rely on published claims for the table below. Each row is checked by the test file in the code section. Where the error space is small enough (every digit, every pair of digits), the tests check all of it, and they also try every possible change on hundreds of random numbers.
| Error | Example | Caught? | Why |
|---|---|---|---|
| One digit wrong | …3 → …8 | Always | Both the plain and the doubled values use each of 0–9 exactly once, so changing a digit always changes the total mod 10. |
| Two neighbouring digits swapped | 51 → 15 | Always, except 09 ↔ 90 | See the explanation below the table. |
| Twin error (a repeated pair mistyped as another pair) | 22 → 55 | Except 22↔55, 33↔66, 44↔77 | A pair aa always counts as a + doubled a. That comes to 6 for both 22 and 55, 9 for 33 and 66, and 12 (so 2) for 44 and 77. |
| Swap across one digit | abc → cba | Never | a and c are both doubled or both plain, so the swap leaves the total unchanged. |
| Leading zeros added or dropped | 123… → 0123… | Never | A zero counts as 0 whether doubled or not, and positions are counted from the right, so nothing else moves. |
| A random string of digits | – | 9 in 10 | Whatever the other digits are, exactly one of the ten possible last digits passes. The tests count every 4-digit string: exactly 1,000 of the 10,000 pass. |
Why 09 ↔ 90 is the only neighbouring swap that slips through. When two neighbours a and b swap, one of them moves onto the doubled position and the other moves off it. The total stays the same only if (doubled a − a) and (doubled b − b) are equal mod 10. That difference is d for digits 0–4 and d − 9 for digits 5–9. Taken mod 10, it is different for every digit except 0 and 9, which both give 0. That leaves 09/90 as the single blind spot among the 45 possible pairs, and the exhaustive test agrees.
One hidden digit can be recovered. Because every single-digit change is caught, a valid number with exactly one digit hidden has exactly one way to fill the gap and still pass. The test suite confirms this on random numbers. In practice, hiding a single digit of a card number does nothing. Real masking rules remove far more: the PCI Security Standards Council’s truncation guidance takes “a maximum of the first 6 and last 4 digits” as its baseline for what can be kept.
What a Luhn pass cannot tell you
This section matters more than the arithmetic, because most misuse of the algorithm comes from expecting too much of it.
- It does not prove a card or account exists, is open, or belongs to anyone. Luhn-valid numbers are trivial to make: the round-trip test below creates thousands in milliseconds. Payment providers publish valid-looking test numbers for exactly this reason. Stripe documents 4242 4242 4242 4242 for testing, and Adyen lists dozens more. Adyen describes the Luhn check as one of the “standard card checks that take place in live environments”. It is one check among several, and it runs before any question about the account.
- It is not a security control. There is no secret and no key. Anyone who knows the rule, which is published in a 1960 patent, can compute the check digit.
- It is not fraud prevention. A fraudster’s typed-in number passes as easily as a customer’s. All it does is spare a customer the round trip of a decline caused by a typo.
- It is weak as a detector of card numbers in data. Since one random digit string in ten passes, scanning for “digit runs that pass Luhn” produces many false hits. Microsoft’s data-loss-prevention definition for credit card numbers needs a passing checksum plus a card-number format for its low-confidence match. For high confidence it also needs a nearby keyword or expiry date, within 300 characters.
- A failed check does not always mean the record is useless. In guidance from 2002, now archived, the Canada Revenue Agency told tax preparers that if a SIN failed the check and couldn’t be corrected, they should still report the number provided, because “even an incorrect number will enable us to find a match”. A failure means the number was probably mistyped. What to do next is a business decision.
The same algorithm, different number systems
Luhn always works on a string of digits, but each numbering scheme decides which digits are covered. Getting that wrong is a common bug.
| Number | Digits covered | Detail that trips people up | Published example (check digit last) |
|---|---|---|---|
| Payment card number | The whole number; the check digit is the last digit | Lengths vary (Microsoft’s detector uses 14–19 digits), so count positions from the right | 4242 4242 4242 4242 (Stripe test card) |
| IMEI (mobile equipment) | The 14 digits of TAC + serial number | The check digit “is not part of the digits transmitted when the IMEI is checked”. The 16-digit IMEISV follows the 14 digits with a two-digit software version number instead, and the check digit does not cover it | 26053179311383 → 7 |
| Canadian SIN | All 9 digits | The CRA describes the same formula for the first nine digits of a Business Number | 999 999 998 (CRA example) |
| US NPI (health providers) | The 10-digit NPI with an implied prefix 80840 | The check digit is always computed as if the prefix were present. Without it, CMS says to add a constant 24. Plain Luhn over the 10 digits gives the wrong answer | 123456789 → 3 (1234567893) |
The CMS example shows why scope matters. 1234567893 is the correct NPI from that document, but it fails a plain Luhn check. It only passes as 808401234567893. The test suite checks both cases.
JavaScript (Node.js) implementation
These choices are deliberate, and each one is covered by a test:
- Strings only. A JavaScript
Numberstores integers exactly only up toNumber.MAX_SAFE_INTEGER(9,007,199,254,740,991, which is 2 to the power 53, minus 1). A 19-digit card number quietly loses its last digits, which the example output below shows. Integers also drop leading zeros. The functions throw aTypeErrorfor numbers, BigInts and anything else that is not a string. - ASCII digits only. Full-width and Arabic-Indic digits are rejected rather than converted.
- Separators are opt-in. Spaces and hyphens are removed only when you pass
{ allowSeparators: true }. Any other character is an error. - Malformed input throws; a wrong check digit returns
false. Your UI can then say “that isn’t a number” and “check the number you typed” as two different messages. - Minimum length. Validation needs at least two digits, one data digit and the check digit, because a lone check digit checks nothing. Computing a check digit needs at least one. There is no maximum and no card-length rule, because length belongs to the numbering scheme, not to Luhn.
// Luhn (mod 10) check digit: compute, checksum and validate.
// Input rules (deliberately strict):
// - The number must be a string. JavaScript numbers lose digits above 2^53
// (about 16 digits), so a Number or BigInt is rejected, never converted.
// - Only ASCII digits 0-9. Spaces and hyphens are accepted only when the
// caller passes { allowSeparators: true }; they are then removed.
// - isValidLuhn needs at least 2 digits (one data digit plus the check digit);
// computeCheckDigit needs at least 1 digit. No maximum length and no
// card-length rule: lengths belong to the numbering scheme, not to Luhn.
// Bad input throws (TypeError for the wrong type, RangeError for bad content),
// so "malformed" is never confused with "check digit wrong" (which is false).
const DIGITS = /^[0-9]+$/;
function digitsOf(input, { allowSeparators = false } = {}) {
if (typeof input !== 'string') {
throw new TypeError(`expected a string of digits, got ${typeof input}`);
}
const s = allowSeparators ? input.replace(/[ -]/g, '') : input;
if (s.length === 0) throw new RangeError('no digits');
if (!DIGITS.test(s)) throw new RangeError('only digits 0-9 are allowed');
return s;
}
// Sum of the digits, doubling every second digit counting from the right
// (the rightmost digit is not doubled), with 10..18 reduced to 1..9; mod 10.
// A complete number is Luhn-valid when this returns 0.
export function luhnChecksum(input, options) {
const s = digitsOf(input, options);
let sum = 0;
for (let i = 0; i < s.length; i++) {
let d = s.charCodeAt(s.length - 1 - i) - 48;
if (i % 2 === 1) {
d *= 2;
if (d > 9) d -= 9; // same as adding the two digits of 10..18
}
sum += d;
}
return sum % 10;
}
export function isValidLuhn(input, options) {
const s = digitsOf(input, options);
if (s.length < 2) throw new RangeError('need at least 2 digits');
return luhnChecksum(s) === 0;
}
// Returns the check digit (as a one-character string) to append to `partial`.
export function computeCheckDigit(input, options) {
const s = digitsOf(input, options);
return String((10 - luhnChecksum(s + '0')) % 10);
}
A short script that computes the check digit for the worked example, validates it, and shows the typing errors from above, the separator option and the Number precision problem:
import { luhnChecksum, isValidLuhn, computeCheckDigit } from './luhn.mjs';
// 1. Compute a check digit, showing each step (synthetic number, not a card).
const partial = '73095184266';
const padded = partial + '0'; // placeholder where the check digit will go
const rows = [];
let sum = 0;
for (let i = 0; i < padded.length; i++) {
const d = Number(padded[padded.length - 1 - i]);
const dbl = i % 2 === 1;
const v = dbl ? (2 * d > 9 ? 2 * d - 9 : 2 * d) : d;
sum += v;
rows.unshift(i === 0 ? '?' : `${d}${dbl ? '->' + v : ''}`);
}
console.log('digits, check position as ?, doubled digits as d->value:');
console.log(' ' + rows.join(' '));
console.log(`sum = ${sum}, sum mod 10 = ${sum % 10}`);
console.log(`check digit = (10 - ${sum % 10}) mod 10 = ${computeCheckDigit(partial)}`);
const full = partial + computeCheckDigit(partial);
console.log(`complete number: ${full} valid: ${isValidLuhn(full)}`);
// 2. Validation catches typing errors...
const swap = (s, i) => s.slice(0, i) + s[i + 1] + s[i] + s.slice(i + 2);
console.log('last digit wrong:', isValidLuhn(full.slice(0, -1) + '1'));
console.log('5 and 1 swapped :', isValidLuhn(swap(full, 3)));
// ...but not all of them:
console.log('0 and 9 swapped :', isValidLuhn(swap(full, 2)), '<- not caught');
// 3. Real-world input: separators must be allowed explicitly.
console.log('Stripe test card:', isValidLuhn('4242 4242 4242 4242', { allowSeparators: true }));
try {
isValidLuhn('4242 4242 4242 4242');
} catch (e) {
console.log('without option :', `${e.name}: ${e.message}`);
}
// 4. Why numbers must stay strings: a 19-digit test PAN as a JS Number.
const asNumber = 6205500000000000004; // Stripe's 19-digit UnionPay test card
console.log('as Number :', String(asNumber), '-> digits silently changed');
try {
isValidLuhn(asNumber);
} catch (e) {
console.log('rejected :', `${e.name}: ${e.message}`);
}
console.log('checksum of test card 6205500000000000004:', luhnChecksum('6205500000000000004'));
Its output:
digits, check position as ?, doubled digits as d->value:
7->5 3 0->0 9 5->1 1 8->7 4 2->4 6 6->3 ?
sum = 43, sum mod 10 = 3
check digit = (10 - 3) mod 10 = 7
complete number: 730951842667 valid: true
last digit wrong: false
5 and 1 swapped : false
0 and 9 swapped : true <- not caught
Stripe test card: true
without option : RangeError: only digits 0-9 are allowed
as Number : 6205500000000000000 -> digits silently changed
rejected : TypeError: expected a string of digits, got number
checksum of test card 6205500000000000004: 0
The tests
Run with node --test. The test vectors come from the sources listed at the end of this page, not from other code: the patent’s own example, the IMEI example in 3GPP TS 23.003 Annex B, the CRA’s SIN example, the CMS NPI examples (including the case that fails without the prefix), and Stripe and Adyen test card numbers of 14, 15, 16 and 19 digits. The property tests cover the error table above: every single-digit change on 300 random numbers, all 45 neighbouring digit pairs, twin errors, swaps across one digit, leading zeros, and exactly one completion for a hidden digit. We also deliberately broke the implementation (doubled the wrong digits, dropped the “subtract 9”, widened the separator set, lowered the minimum length, skipped the type check). Each change made at least one test fail.
// Run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { luhnChecksum, isValidLuhn, computeCheckDigit } from './luhn.mjs';
// Deterministic pseudo-random digits so every run tests the same strings.
let seed = 20261010;
const rnd = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
const randomDigits = (len) => Array.from({ length: len }, () => rnd(10)).join('');
const randomValid = (len) => { const p = randomDigits(len - 1); return p + computeCheckDigit(p); };
// Contribution of one digit: as written, or doubled with 10..18 -> 1..9.
const plain = (d) => d;
const doubled = (d) => (2 * d > 9 ? 2 * d - 9 : 2 * d);
test('worked example from H. P. Luhn, US patent 2,950,048 (1960)', () => {
assert.equal(computeCheckDigit('4872148'), '4');
assert.equal(isValidLuhn('48721484'), true);
});
test('IMEI example, 3GPP TS 23.003 Annex B.3', () => {
assert.equal(computeCheckDigit('26053179311383'), '7');
assert.equal(isValidLuhn('260531793113837'), true);
});
test('SIN example, Canada Revenue Agency "Validating a Social Insurance Number"', () => {
assert.equal(computeCheckDigit('99999999'), '8');
assert.equal(isValidLuhn('999999998'), true);
});
test('NPI examples, CMS "Requirements for NPI and NPI Check Digit" (2004)', () => {
assert.equal(computeCheckDigit('80840123456789'), '3');
assert.equal(isValidLuhn('808401234567893'), true);
// The 10-digit NPI 1234567893 is only valid WITH the 80840 prefix:
assert.equal(isValidLuhn('1234567893'), false);
});
test('published processor test card numbers pass', () => {
const stripe = [ // docs.stripe.com/testing, "Card numbers"
'4242424242424242', '5555555555554444', '2223003122003222', '378282246310005',
'6011111111111117', '3056930009020004', '36227206271667', '3566002020360505',
'6200000000000005', '6205500000000000004',
];
const adyen = [ // docs.adyen.com, "Test card numbers"
'370000000000002', '36006666333344', '5555444433331111', '4111111111111111',
'6771798021000008', '135410014004955', '4013250000000000006',
];
for (const n of [...stripe, ...adyen]) assert.equal(isValidLuhn(n), true, n);
// Stripe documents 4242424242424241 as a number that fails the Luhn check.
assert.equal(isValidLuhn('4242424242424241'), false);
});
test('round trip: computeCheckDigit then isValidLuhn, and only that digit works', () => {
for (let k = 0; k < 2000; k++) {
const partial = randomDigits(1 + rnd(30));
const cd = computeCheckDigit(partial);
for (let d = 0; d <= 9; d++) {
assert.equal(isValidLuhn(partial + d), String(d) === cd, partial + d);
}
}
});
test('exactly 1 in 10 of all digit strings is valid (all 4-digit strings)', () => {
let valid = 0;
for (let n = 0; n < 10000; n++) if (isValidLuhn(String(n).padStart(4, '0'))) valid++;
assert.equal(valid, 1000);
});
test('every single-digit error is detected', () => {
// Proof: both digit maps are permutations of 0..9, so changing one digit
// always changes the sum by a non-zero amount mod 10.
for (const f of [plain, doubled]) {
assert.equal(new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(f)).size, 10);
}
// Brute force on 300 random valid numbers: every position, every wrong digit.
for (let k = 0; k < 300; k++) {
const n = randomValid(2 + rnd(18));
for (let i = 0; i < n.length; i++) {
for (let d = 0; d <= 9; d++) {
if (String(d) === n[i]) continue;
assert.equal(isValidLuhn(n.slice(0, i) + d + n.slice(i + 1)), false);
}
}
}
});
test('adjacent transpositions: 09 <-> 90 is the only one missed', () => {
const missed = [];
for (let a = 0; a <= 9; a++) {
for (let b = a + 1; b <= 9; b++) {
// Either digit of the pair can sit on the doubled position.
const sameSum1 = (plain(a) + doubled(b)) % 10 === (plain(b) + doubled(a)) % 10;
const sameSum2 = (doubled(a) + plain(b)) % 10 === (doubled(b) + plain(a)) % 10;
if (sameSum1 || sameSum2) missed.push(`${a}${b}`);
}
}
assert.deepEqual(missed, ['09']);
// And in real numbers: swap every unequal adjacent pair.
for (let k = 0; k < 300; k++) {
const n = randomValid(3 + rnd(17));
for (let i = 0; i + 1 < n.length; i++) {
if (n[i] === n[i + 1]) continue;
const swapped = n.slice(0, i) + n[i + 1] + n[i] + n.slice(i + 2);
const pair = [n[i], n[i + 1]].sort().join('');
assert.equal(isValidLuhn(swapped), pair === '09', `${n} -> ${swapped}`);
}
}
});
test('twin errors aa -> bb: exactly 22/55, 33/66 and 44/77 are missed', () => {
const missed = [];
for (let a = 0; a <= 9; a++) {
for (let b = a + 1; b <= 9; b++) {
if ((a + doubled(a)) % 10 === (b + doubled(b)) % 10) missed.push(`${a}${a}-${b}${b}`);
}
}
assert.deepEqual(missed, ['22-55', '33-66', '44-77']);
const base = '1122' + '0000'; // synthetic
const n = base + computeCheckDigit(base);
assert.equal(isValidLuhn(n), true);
assert.equal(isValidLuhn(n.replace('22', '55')), true); // twin error slips through
assert.equal(isValidLuhn(n.replace('11', '88')), false); // most are caught
});
test('jump transpositions (abc -> cba) are never detected', () => {
for (let k = 0; k < 300; k++) {
const n = randomValid(3 + rnd(17));
for (let i = 0; i + 2 < n.length; i++) {
const j = n.slice(0, i) + n[i + 2] + n[i + 1] + n[i] + n.slice(i + 3);
assert.equal(isValidLuhn(j), true);
}
}
});
test('leading zeros never change the result', () => {
for (let k = 0; k < 200; k++) {
const n = randomValid(2 + rnd(18));
assert.equal(isValidLuhn('0' + n), true);
assert.equal(isValidLuhn('00' + n), true);
}
});
test('one hidden digit has exactly one valid completion', () => {
for (let k = 0; k < 200; k++) {
const n = randomValid(8 + rnd(12));
const i = rnd(n.length);
const fits = [];
for (let d = 0; d <= 9; d++) if (isValidLuhn(n.slice(0, i) + d + n.slice(i + 1))) fits.push(String(d));
assert.deepEqual(fits, [n[i]]);
}
});
test('separators only with the explicit option', () => {
assert.throws(() => isValidLuhn('4242 4242 4242 4242'), RangeError);
assert.equal(isValidLuhn('4242 4242 4242 4242', { allowSeparators: true }), true);
assert.equal(isValidLuhn('3782-822463-10005', { allowSeparators: true }), true);
assert.equal(computeCheckDigit('2605317 9311383', { allowSeparators: true }), '7');
assert.throws(() => isValidLuhn('4242.4242.4242.4242', { allowSeparators: true }), RangeError);
assert.throws(() => isValidLuhn(' - ', { allowSeparators: true }), RangeError);
});
test('input errors', () => {
for (const bad of [4242424242424242, 42n, null, undefined, ['42'], { n: '42' }, true]) {
assert.throws(() => isValidLuhn(bad), TypeError);
assert.throws(() => computeCheckDigit(bad), TypeError);
assert.throws(() => luhnChecksum(bad), TypeError);
}
for (const bad of ['', ' ', 'abc', '42a', '+42', '4.2', '-42', '\n42', '\uFF14\uFF12', '\u0664\u0662']) {
assert.throws(() => isValidLuhn(bad), RangeError, JSON.stringify(bad));
assert.throws(() => computeCheckDigit(bad), RangeError, JSON.stringify(bad));
}
assert.throws(() => isValidLuhn('0'), RangeError); // a check digit alone checks nothing
assert.equal(computeCheckDigit('0'), '0');
assert.equal(luhnChecksum('0'), 0);
});
Python version
The same rules and API in Python. Watch out for str.isdigit(): it also accepts superscripts and digits from other scripts, so the function checks for the characters 0–9 explicitly. Accept strings, not integers, so leading zeros survive. A unittest file (test_luhn.py) covers the same published vectors, round trips, single-digit errors and bad input.
"""Luhn (mod 10) check digit, same rules as luhn.mjs.
Strings only (an int would drop leading zeros); ASCII digits only; spaces and
hyphens only with allow_separators=True. is_valid_luhn needs >= 2 digits.
Wrong type raises TypeError, bad content raises ValueError.
"""
def _digits(s, allow_separators=False):
if not isinstance(s, str):
raise TypeError(f"expected a string of digits, got {type(s).__name__}")
if allow_separators:
s = s.replace(" ", "").replace("-", "")
# str.isdigit() accepts other scripts and superscripts, so test ASCII explicitly
if not s or not all("0" <= c <= "9" for c in s):
raise ValueError("only digits 0-9 are allowed")
return s
def luhn_checksum(s, allow_separators=False):
total = 0
for i, c in enumerate(reversed(_digits(s, allow_separators))):
d = ord(c) - 48
if i % 2 == 1:
d = d * 2 - 9 if d > 4 else d * 2
total += d
return total % 10
def is_valid_luhn(s, allow_separators=False):
s = _digits(s, allow_separators)
if len(s) < 2:
raise ValueError("need at least 2 digits")
return luhn_checksum(s) == 0
def compute_check_digit(partial, allow_separators=False):
return str((10 - luhn_checksum(_digits(partial, allow_separators) + "0")) % 10)
Handling card numbers in code that validates them
The check digit itself is not sensitive, but the number you are checking usually is. Two short points, and neither is compliance advice:
- Don’t write full card numbers to logs, error messages or analytics. PCI DSS requires the primary account number to be “rendered unreadable when it is stored” (Requirement 3.5.1, as summarised in the PCI SSC’s own FAQ). A validation error that echoes the input into a log file stores it. Log the result and, if you must, a truncated form.
- In tests, use published test numbers, not real cards. Stripe goes further and doesn’t recommend putting card numbers directly in API calls or server-side code even in testing, because “your code might not be PCI-compliant when you go live”.
Frequently asked questions
How do I check if a credit card number is valid with the Luhn algorithm?
Starting from the rightmost digit, leave it alone, double the next one, and keep alternating to the left. Subtract 9 from any doubled value above 9, add everything up, and the number passes if the total ends in 0. A pass only means the number is well-formed. Whether the card is real and usable is something only a payment authorization can tell you.
What is a mod 10 check digit?
It is a final digit chosen so that a weighted sum of all the digits is a multiple of 10. “Mod 10” and “modulus 10” usually mean the Luhn scheme on this page. CMS calls it the modulus 10 “double-add-double” check digit.
Does passing the Luhn check mean a card number is real?
No. One random digit string in ten passes, and valid-looking numbers can be generated in bulk. Luhn catches typing mistakes. It says nothing about whether an account exists, is active or belongs to the person entering it.
Why does Luhn double every second digit from the right instead of the left?
Counting from the right keeps the check digit in the same, undoubled position however long the number is. Card numbers vary in length, so counting from the left would double different digits for a 15-digit and a 16-digit card. Luhn’s 1960 patent solved the same problem by choosing the starting digit according to whether the number has an odd or even number of digits.
Does the Luhn algorithm catch all transposition errors?
It catches every swap of two neighbouring digits except 0 and 9 (09 ↔ 90). It never catches a swap across one digit (abc → cba). It misses the twin errors 22↔55, 33↔66 and 44↔77. Every single-digit error is caught.
Is the IMEI check digit the same Luhn algorithm?
Yes. 3GPP TS 23.003 defines the IMEI check digit with the Luhn method over the 14 digits of the type allocation code and serial number. When an IMEI is sent to the network to be checked, the specification leaves the check digit out. The 16-digit IMEISV carries a two-digit software version number instead.
Can I store a card number as an integer?
No. In JavaScript, integers above 9,007,199,254,740,991 (16 digits) lose precision, and card numbers can be up to 19 digits long. In every language an integer drops leading zeros, which Luhn cannot detect anyway. Keep card numbers as strings of digits.
Related reading on Ambimat
- Credit card service code chart – another field read from the card, and what its digits mean.
- How an EMV contact card payment works, step by step – where the real checks on a card happen.
- What is token-based authentication – how APIs authenticate requests with short-lived tokens instead of re-sending a password.
- Application identifier (AID) reference list – how payment applications on a chip are identified.
- CyberChef – a browser toolkit for decoding and converting data during development.
Sources
- H. P. Luhn (assignor to International Business Machines Corporation), Computer for Verifying Numbers, US patent 2,950,048, filed 6 January 1954, granted 23 August 1960; description (substitute digits, end-around carry, check digit, worked example 4872148 → 4): patents.google.com
- 3GPP / ETSI, TS 23.003 Numbering, addressing and identification, version 19.8.0 Release 19 (ETSI TS 123 003 V19.8.0, 2026-10), clause 6.2.1 (composition of IMEI) and Annex B (IMEI check digit computation, example B.3): etsi.org
- Canada Revenue Agency, Validating a Social Insurance Number – Example (PDF dated 2002), as preserved by Library and Archives Canada’s Government of Canada Web Archive, capture of 10 December 2006: bac-lac.wayback.archive-it.org
- Centers for Medicare & Medicaid Services, Requirements for National Provider Identifier (NPI) and NPI Check Digit, 23 January 2004, “Requirements for NPI Check Digit” and both worked examples: cms.gov
- Stripe, Testing (documentation), sections “Test code”, “Simulate a payment by card brand” and “Trigger an error with invalid data”, retrieved 10 October 2026: docs.stripe.com
- Adyen, Test card numbers (documentation, last modified 21 October 2024), card tables by brand: docs.adyen.com
- Adyen, Create test cards (documentation, last modified 27 February 2026), “Test card requirements” and “Test card response handling”: docs.adyen.com
- Microsoft Learn, Credit card number entity definition (Microsoft Purview sensitive information types), sections “Format”, “Checksum” and “Definition”: learn.microsoft.com
- PCI Security Standards Council, FAQ 1222, Does cardholder name, expiration date, etc. need to be rendered unreadable if stored in conjunction with the PAN? (June 2025): pcisecuritystandards.org; FAQ 1091, What are acceptable formats for truncation of primary account numbers? (June 2022): pcisecuritystandards.org
- MDN Web Docs, Number.MAX_SAFE_INTEGER: developer.mozilla.org
- Python Software Foundation, Built-in Types,
str.isdigit(): docs.python.org - ISO/IEC 7812-1 (the ISO/IEC 7812 series is cited by 3GPP TS 23.003 as Identification cards – Numbering system and registration procedure for issuer identifiers): a paid ISO standard, named for reference only and not consulted for this article.
Written by the Ambimat engineering team. The JavaScript code and its tests were run on Node.js 25.8.2; the Python version was tested with Python 3.14.