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 same strlen($digits) < 2 guard as the snippet above, for the same reason.

composer require --dev ccgenerator/test-cards

For Laravel it adds a test_card validation 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

  1. Empty input treated as valid. With no digits the sum is 0, and 0 % 10 == 0 is true. A length guard is the fix, and it has to be at least 2 — an empty check alone still lets "0" through.
  2. 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.
  3. Parsing without filtering. parseInt on a whole string rather than a character, or forgetting to strip the spaces and dashes people paste, produces silently wrong results rather than errors.
  4. 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.
  5. Unicode digits. Python’s isdigit() accepts Arabic-Indic and other non-ASCII digits and int() converts them; JavaScript’s \d matches ASCII only. The two languages disagree about the same input, so pin the character range explicitly.
  6. 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:

LanguageRuntime used
JavaScriptNode.js 22
PythonCPython 3.9
PHPPHP 8.5
RubyRuby 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 generatorbrand 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.

Frequently Asked Questions

Whichever one your form already runs in, and then again on the server. Luhn is a dozen lines in every language and the implementations here are equivalent — there is no performance or correctness reason to prefer one. The meaningful decision is not the language but running the check in both places, because anything the browser validates can be bypassed.
Both. Client-side is for immediate feedback when someone mistypes a digit, which is the entire purpose of the checksum. Server-side is the one you can trust, since the client-side check is advisory and easily skipped. Neither replaces the payment provider’s authorisation, which is the only check that establishes whether an account exists.
Several, and for a fifteen-line function a dependency is usually the wrong trade. Every package you add is a supply-chain surface on your payment page specifically. If you do use one, prefer a maintained library with minimal transitive dependencies, and keep your own test vectors so you notice if its behaviour changes.
No. The algorithm is linear in the number of digits and there are at most nineteen of them, so a single check costs well under a microsecond in any of these languages. If you are validating millions of rows in a batch job the allocation pattern starts to matter, but in a form it is unmeasurable next to a single DOM update.
With the shared vectors on this page. They cover 13, 14, 15 and 16-digit numbers across four networks, a number with a single altered digit, a random string, empty input, non-numeric input, and a single zero — which is the case naive implementations silently accept because a checksum of zero is divisible by ten.