20.11.2025 • 4 min read

Luhn: Checking the Digits Before a Payment

How can a payment form spot a mistyped card number before sending it to the payment provider?

One common check is the Luhn checksum. It is quick enough to give early feedback, but a passing result means much less than "this card works."

Let's calculate it on a small example, write the check, and see one mistake it deliberately can't catch.

The last digit helps check the others

For identifiers that use Luhn, the final digit is a check digit. The calculation uses all the digits, including that one, to see whether the total is divisible by ten.

Starting from the right-hand end of the complete number:

  1. Leave the rightmost digit unchanged.
  2. Double every second digit as you move left.
  3. If a doubled value is greater than nine, subtract nine.
  4. Add the resulting values and check whether the total is a multiple of ten.

Subtracting nine is equivalent to adding the digits of a doubled value from 10 to 18. For example, doubling 8 gives 16, and 1 + 6 gives the same contribution as 16 - 9.

The original Luhn checksum illustration

The direction matters. Starting with the wrong alternating position checks a different pattern, so test both odd- and even-length inputs.

Calculate one complete example

Use 79927398713, a classic checksum demonstration rather than a claim about a real payment card. From left to right, its transformed contributions are:

Input:          7 9 9 2 7 3 9 8 7 1 3
Contributions:  7 9 9 4 7 6 9 7 7 2 3
Total: 70

The total is divisible by ten, so the checksum passes. Changing the final digit to 4 gives a total of 71, which fails.

That is enough to explain the early feedback: we can detect an inconsistent check digit without asking a remote service. We haven't contacted an issuer or checked any account.

Keep the implementation focused

This Python function accepts ASCII digits, allowing spaces and hyphens as display separators:

def passes_luhn(value):
    digits = value.replace(" ", "").replace("-", "")
    if not digits or any(ch not in "0123456789" for ch in digits):
        return False
    total = 0
    for position, ch in enumerate(reversed(digits)):
        digit = int(ch)
        if position % 2 == 1:
            digit *= 2
            if digit > 9:
                digit -= 9
        total += digit
    return total % 10 == 0

assert passes_luhn("79927398713")
assert not passes_luhn("79927398714")
assert not passes_luhn("")

It demonstrates the checksum, not a complete card-validation library. The payment integration still needs the provider's rules for supported identifiers, length and other required fields. Use the supported payment UI and tokenization flow for real card entry rather than treating this exercise as a reason to collect card details yourself.

Harvard's CS50 credit exercise also walks through the checksum calculation. Its value here is error detection, not cryptographic protection.

Some wrong inputs can still pass

Luhn catches single-digit substitutions and many adjacent swaps, but not every possible error. The pair 09 can be swapped with 90 without changing the relevant contribution.

With the function above, these short teaching strings both pass:

assert passes_luhn("109")
assert passes_luhn("190")

They aren't card examples; they make the checksum limitation easy to test. Also, a person can deliberately calculate a matching check digit for invented data. Passing isn't proof that an identifier was issued.

If we can calculate a valid check digit ourselves, what could the checksum actually prove about a card's owner?

Nothing about ownership. The check tests a digit relationship. It doesn't establish a balance, expiry, permission to pay or successful authorization.

Use it for feedback at the right time

Don't interrupt someone with an invalid-number warning after their first keystroke. Apply feedback when there is enough input to make it useful, following the supported payment component's behaviour.

Keep authoritative validation on the payment path. A client-side check can be bypassed, and the provider must still process the request under its own contract. Avoid putting sensitive full numbers into logs just to record whether the check passed.

Luhn can reduce avoidable requests and help a user correct a typo. It doesn't establish what a provider charges for failed attempts, so don't attach a universal fee-saving promise to it.

A good check has a clear meaning

The useful result is "these digits pass this checksum," followed by the other validation the payment flow requires. The useful failure is early, understandable feedback that lets the user correct their input.

Run the valid example, change a digit, then try the undetected swap. That small experiment captures both the convenience and the boundary of Luhn. It's a helpful first check precisely because we know what it can and cannot tell us.

Try it yourself

Both 109 and 190 pass the example Luhn function. What does that demonstrate, and would either result establish a usable payment card?

A possible solution

It demonstrates the undetected 09-to-90 adjacent swap in these short teaching strings. Luhn checks a digit relationship and cannot catch every error or prove issuance, ownership, balance, expiry or authorization. These strings are not payment-card examples. A real payment flow still requires its provider's supported validation and authorization.

Share this article with your friends

Know someone who would enjoy this? Send it their way.

WhatsAppLinkedInXEmail