The Luhn algorithm doubles every second digit from the right, subtracts 9 from any result above 9, sums the lot, and checks whether the total divides by 10. It catches mistyped digits and nothing else — the full explanation covers why it works and what it misses.
This page is the reference implementation, in four languages, executed against a shared set of test vectors before publication.
The JavaScript version also ships as a package.
isLuhnValid,luhnCheckDigit, brand detection and a synthetic card generator built on the same network rules are published together as@ccgenerator/test-cards— zero dependencies, no install scripts, MIT, with the source on GitHub. It exists for test suites —generate('visa')in a fixture instead of a hard-coded number — not as a production checkout dependency; for a fifteen-line function, the FAQ below still applies.
The shared test vectors
Use the same set everywhere. Half the value of a checksum function is knowing it fails on the right inputs, and the last three cases below are the ones that catch naive code.
VALID
4539148803436467 Visa, 16 digits
5425233430109903 Mastercard, 16 digits
374245455400126 American Express, 15 digits
6011111111111117 Discover, 16 digits
4222222222222 Visa, 13 digits
30569309025904 Diners Club, 14 digits
INVALID
4539148803436460 last digit altered
1234567812345678 arbitrary digits
"" empty input
"abc" no digits at all
"0" a single zero
The single zero deserves a note, because it is not obvious. A lone 0 genuinely satisfies
the checksum — the sum is zero and zero is divisible by ten — so an implementation that
guards only against an empty string still returns true for it. Every function below
therefore requires at least two digits. A real card number requires far more than that, and
the length rules per network belong in a separate
check alongside this one.
Why these vectors
The valid set is not arbitrary. It covers four networks and four different lengths — 13, 14, 15 and 16 digits — because the single most common Luhn bug is a loop that iterates in the wrong direction, and that bug is invisible unless your fixtures include an odd-length number. A suite built only from 16-digit Visa and Mastercard numbers will pass against an implementation that is wrong for every American Express card in production.
The invalid set covers three different failure modes rather than three examples of one. An altered final digit tests the checksum itself. A run of arbitrary digits tests that you are not accidentally accepting anything well-formed — remembering that roughly one random string in ten passes Luhn by chance, so a single random vector is a weak test and you should not be surprised when one occasionally has to be replaced. Empty input, non-numeric input and the lone zero test the guards rather than the arithmetic, and those are the three that catch real bugs in code review.
Property-based testing
Fixed vectors prove specific cases. If you want stronger coverage for very little effort, assert the round trip instead: generate a random payload, compute its check digit, append it, and assert the validator accepts the result — then alter any single digit and assert it rejects. A few hundred iterations of that exercises both functions against each other across every length you care about, and it fails loudly on the direction bug that fixed vectors can miss. It also documents the relationship between the two functions better than prose does, since the check digit is part of the number’s structure rather than a separate artefact.
JavaScript and TypeScript
Iterating from the end with charCodeAt avoids allocating an array or a substring per
digit:
function luhnValid(input) {
const digits = String(input).replace(/[^0-9]/g, '');
if (digits.length < 2) return false;
let sum = 0;
let double = false;
for (let i = digits.length - 1; i >= 0; i--) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return sum % 10 === 0;
}
The TypeScript signature is the only change needed — the body is identical:
export function luhnValid(input: string | number): boolean {
const digits = String(input).replace(/[^0-9]/g, '');
if (digits.length < 2) return false;
// …as above
}
A Vitest or Jest suite over the shared vectors:
import { describe, expect, it } from 'vitest';
import { luhnValid } from './luhn';
const VALID = ['4539148803436467', '5425233430109903', '374245455400126',
'6011111111111117', '4222222222222', '30569309025904'];
const INVALID = ['4539148803436460', '1234567812345678', '', 'abc', '0'];
describe('luhnValid', () => {
it.each(VALID)('accepts %s', (n) => expect(luhnValid(n)).toBe(true));
it.each(INVALID)('rejects %s', (n) => expect(luhnValid(n)).toBe(false));
it('ignores spaces and dashes', () =>
expect(luhnValid('4539 1488-0343 6467')).toBe(true));
});
Python
Note the digit filter. str.isdigit() is the obvious choice and the wrong one: it returns
True for Arabic-Indic and other Unicode digit characters, which int() will then happily
convert — so a number typed on a non-Latin keyboard passes a check your regex-based
validator elsewhere rejects. An explicit ASCII range is unambiguous.
def luhn_valid(number: str) -> bool:
digits = [ord(c) - 48 for c in str(number) if "0" <= c <= "9"]
if len(digits) < 2:
return False
checksum = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
checksum += d
return checksum % 10 == 0
import pytest
from luhn import luhn_valid
VALID = ["4539148803436467", "5425233430109903", "374245455400126",
"6011111111111117", "4222222222222", "30569309025904"]
INVALID = ["4539148803436460", "1234567812345678", "", "abc", "0"]
@pytest.mark.parametrize("number", VALID)
def test_accepts_valid(number):
assert luhn_valid(number)
@pytest.mark.parametrize("number", INVALID)
def test_rejects_invalid(number):
assert not luhn_valid(number)
PHP
ord() on a string offset is the direct equivalent of the JavaScript version, and
preg_replace handles the separators users paste in:
<?php
function luhn_valid(string $number): bool {
$digits = preg_replace('/[^0-9]/', '', $number);
$len = strlen($digits);
if ($len < 2) return false;
$sum = 0;
$double = false;
for ($i = $len - 1; $i >= 0; $i--) {
$d = ord($digits[$i]) - 48;
if ($double) {
$d *= 2;
if ($d > 9) $d -= 9;
}
$sum += $d;
$double = !$double;
}
return $sum % 10 === 0;
}
<?php
use PHPUnit\Framework\TestCase;
final class LuhnTest extends TestCase {
public function validProvider(): array {
return [['4539148803436467'], ['5425233430109903'], ['374245455400126'],
['6011111111111117'], ['4222222222222'], ['30569309025904']];
}
/** @dataProvider validProvider */
public function testAcceptsValid(string $number): void {
$this->assertTrue(luhn_valid($number));
}
public function testRejectsSingleZero(): void {
$this->assertFalse(luhn_valid('0'));
}
}
The PHP version also ships as a package. The two functions above, plus brand detection and a synthetic card generator built on the same network rules, are published as
ccgenerator/test-cards— PHP 8.1+, zero runtime dependencies, MIT, source on GitHub. It carries the samestrlen($digits) < 2guard as the snippet above, for the same reason.composer require --dev ccgenerator/test-cardsFor Laravel it adds a
test_cardvalidation rule, a Faker provider for model factories and two Artisan commands; for Symfony, a#[TestCardNumber]constraint and the matching console commands. Like the npm package it exists for test suites —TestCards::generate('visa')in a fixture instead of a hard-coded number — not as a production checkout dependency. For a fifteen-line function, the FAQ below still applies.
Ruby
def luhn_valid(number)
digits = number.to_s.gsub(/[^0-9]/, '')
return false if digits.length < 2
sum = 0
double = false
digits.reverse.each_char do |c|
d = c.ord - 48
if double
d *= 2
d -= 9 if d > 9
end
sum += d
double = !double
end
(sum % 10).zero?
end
require 'minitest/autorun'
class LuhnTest < Minitest::Test
VALID = %w[4539148803436467 5425233430109903 374245455400126
6011111111111117 4222222222222 30569309025904].freeze
INVALID = ['4539148803436460', '1234567812345678', '', 'abc', '0'].freeze
def test_accepts_valid
VALID.each { |n| assert luhn_valid(n), "expected #{n} to be valid" }
end
def test_rejects_invalid
INVALID.each { |n| refute luhn_valid(n), "expected #{n} to be invalid" }
end
end
Generating a check digit
Validating and generating are the same arithmetic with the doubling parity flipped, because the payload is one digit shorter than the finished number. Getting this wrong produces a plausible digit that is simply incorrect, and it is the most common bug in hand-written Luhn code.
function luhnCheckDigit(payload) {
const digits = String(payload).replace(/[^0-9]/g, '');
if (!digits) throw new Error('empty payload');
let sum = 0;
let double = true; // starts true — the payload has no check digit
for (let i = digits.length - 1; i >= 0; i--) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return (10 - (sum % 10)) % 10;
}
def luhn_check_digit(payload: str) -> int:
digits = [ord(c) - 48 for c in str(payload) if "0" <= c <= "9"]
if not digits:
raise ValueError("empty payload")
total = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 0: # doubles from the rightmost payload digit
d *= 2
if d > 9:
d -= 9
total += d
return (10 - total % 10) % 10
Both were checked by stripping the final digit from each valid vector and confirming the
function returns it: 453914880343646 → 7, 542523343010990 → 3, 37424545540012 → 6,
601111111111111 → 7, 3056930902590 → 4. That round trip is the test worth writing,
because it exercises validation and generation against each other.
The bugs these functions usually ship with
- Empty input treated as valid. With no digits the sum is 0, and
0 % 10 == 0is true. A length guard is the fix, and it has to be at least 2 — an empty check alone still lets"0"through. - Iterating from the left. Luhn works right to left. A left-to-right implementation is correct on even-length numbers and wrong on odd-length ones, so it passes every test written with 16-digit cards and fails on 15-digit American Express in production.
- Parsing without filtering.
parseInton a whole string rather than a character, or forgetting to strip the spaces and dashes people paste, produces silently wrong results rather than errors. - Converting the number to an integer. A 19-digit card number exceeds a 64-bit signed integer, and JavaScript loses precision above
Number.MAX_SAFE_INTEGER. Card numbers are strings from input to storage — the sum itself never exceeds about 171, so the accumulator can be any integer type. - Unicode digits. Python’s
isdigit()accepts Arabic-Indic and other non-ASCII digits andint()converts them; JavaScript’s\dmatches ASCII only. The two languages disagree about the same input, so pin the character range explicitly. - Missing the UnionPay exception. Some UnionPay ranges are not Luhn-valid at all. A hard reject on a failed checksum declines legitimate cards, so warn rather than block — the check is advisory, and the issuer’s answer is authoritative.
Performance
Luhn is O(n) with n at most 19, which makes a single check sub-microsecond in every language here. Write it for readability.
If you are validating millions of rows in a batch, the only thing worth changing is allocation: reading character codes by index, as all four implementations above do, avoids building an intermediate array per number. That is the difference between roughly a million checks per second in a scripting language and several times that — a distinction that matters in a migration job and nowhere else.
Do not optimise this function. It will not be your bottleneck; the database write next to it will be.
How these were verified
Every snippet on this page was executed against the shared vectors before publication:
| Language | Runtime used |
|---|---|
| JavaScript | Node.js 22 |
| Python | CPython 3.9 |
| PHP | PHP 8.5 |
| Ruby | Ruby 2.6 |
All four accept the six valid vectors and reject all five invalid ones, including the single zero. The check-digit functions were verified by the round trip described above. Paste any of the valid numbers into the validator to confirm independently, or generate more with the card number generator — brand detection is the companion problem, and the one you will reach for next.
The implementations above were last checked against provider documentation on . Providers do change what they publish — the official link beside each claim is authoritative.