Functional Weave
Code in Python

validation.luhn

Check a digit string against the Luhn mod-10 checksum used by payment cards and many identifiers.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 18 tests, run in TypeScript, Python and Rust.

What it does

Luhn is a transcription check, not an authorisation. A number that passes is well formed: a single mistyped digit, and every adjacent transposition except 0<->9, would have failed. A number that passes may still be unissued, closed, stolen or empty. Only the card scheme or issuer can answer that, so never show a user "card valid" because this returned true - the honest wording is "that doesn't look like a complete card number" on false, and nothing at all on true.

Accepted input: ASCII digits, optionally separated by ASCII spaces or hyphens anywhere (including leading and trailing), because cards are printed and pasted in four-digit groups. Any other character, including underscores, dots, non-breaking spaces and non-ASCII digits, makes the whole value invalid rather than being skipped.

For example

  • is_luhn(79927398713) → true the published Luhn worked example 79927398713
  • is_luhn(79927398710) → false the same example with its last digit wrong
  • is_luhn(4242424242424242) → true a Visa test card number

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def is_luhn(value: str) -> bool
valuestringDigits, optionally grouped with ASCII spaces or hyphens
returnsbool

Your code names it in one line, in the file that uses it

from fune.validation.luhn import is_luhn  # validation.luhn@^1
impl/python.py · 72 lines · open · raw
from typing import List


def is_luhn(value: str) -> bool:
    """The Luhn (mod 10) checksum, as used by payment card numbers, IMEIs,
    SIM ICCIDs and a long tail of national identifiers.

    Luhn is a *transcription* check. It catches a single mistyped digit and
    most adjacent transpositions before a request leaves the building. It says
    nothing about whether the card exists, is open, belongs to the person
    typing it, or has any money behind it. Only the acquirer can answer that,
    so never render "card valid" on the strength of this function.
    """
    # Non-strings are answered rather than raised at: "is this valid?" is a
    # question, and "no" is a complete answer to it.
    if not isinstance(value, str):
        return False

    digits: List[int] = []
    for ch in value:
        # Card numbers are printed and pasted in four-digit groups, so
        # tolerating the separators here saves every caller the same strip.
        if ch == " " or ch == "-":
            continue
        if ch < "0" or ch > "9":
            return False
        digits.append(ord(ch) - 48)

    # A lone digit satisfies the arithmetic whenever it is 0, which would make
    # this function a rubber stamp for a stray keystroke. No identifier scheme
    # issues one-digit numbers, so two is the honest floor.
    if len(digits) < 2:
        return False

    total = 0
    for position, d in enumerate(reversed(digits)):
        if position % 2 == 1:
            d *= 2
            # Doubling can only reach 18, so subtracting 9 is the same as
            # summing the two decimal digits, without the string round trip.
            if d > 9:
                d -= 9
        total += d
    return total % 10 == 0


def luhn_check_digit(prefix: str) -> int:
    """The digit that would make ``prefix`` pass the Luhn check.

    Useful for generating test data and for completing a partially known
    number; it is the inverse of the check above, not a second opinion on it.
    Returns -1 when the prefix is not usable digits at all.
    """
    digits: List[int] = []
    for ch in prefix:
        if ch == " " or ch == "-":
            continue
        if ch < "0" or ch > "9":
            return -1
        digits.append(ord(ch) - 48)
    if not digits:
        return -1

    total = 0
    # The check digit will occupy position 0, so the prefix starts at 1.
    for position, d in enumerate(reversed(digits)):
        if position % 2 == 0:
            d *= 2
            if d > 9:
                d -= 9
        total += d
    return (10 - (total % 10)) % 10

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add validation.luhn
Download for Python validation.luhn-1.0.0-python.fune · 7,338 bytes sha256 09a48269c001b228e666fbb0de4f0c7669b6274b0792287b12332e424823a19b

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.luhn-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.luhn@1.0.0:python.

The whole function, every language, is one file too: validation.luhn-1.0.0.fune, 13,049 bytes, sha256 0ca77e1af7ac5c6493cdc4737614cd723c89e632555685d909d4dd957a24d1d6. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before validation.luhn

after — your function gets the result and the arguments, and returns the final result.

# fune: after validation.luhn

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.luhn --steps.

# fune: step validation.luhn after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
the published Luhn worked example 79927398713 79927398713 → true
the same example with its last digit wrong 79927398710 → false
a Visa test card number 4242424242424242 → true
the same card printed in four-digit groups 4242 4242 4242 4242 → true
the same card hyphenated 4242-4242-4242-4242 → true
a Mastercard test card number 5555555555554444 → true
an American Express test card number, fifteen digits 378282246310005 → true
a published IMEI, so Luhn is not only about cards 490154203237518 → true
right length, last digit off by one 4242424242424241 → false
two adjacent digits transposed 378282246310050 → false
Show the other 8 tests
CaseArgumentsExpected
the empty string is not a number → false
separators with no digits between them - → false
a single zero satisfies the arithmetic but is refused 0 → false
two digits is the shortest accepted input 00 → true
underscore is not an accepted separator 4242_4242_4242_4242 → false
a letter anywhere fails 4242 4242 4242 424X → false
leading and trailing spaces are ignored like any other separator 4111 1111 1111 1111 → true
a non-string argument is not a number 42 → false

More from the author

At least two digits are required. A single "0" satisfies the arithmetic, and accepting it would turn this into a rubber stamp for a stray keystroke.

This capability deliberately does not check length or issuer prefix. A 16-digit Visa, a 15-digit Amex and a 15-digit IMEI all use the same checksum, and baking card-brand rules in here would make it useless for everything else. Pair it with a separate brand check if you need one.

Validators answer rather than throw: an unparseable value is not an exceptional condition, it is the answer "no". A non-string argument is therefore false, not a TypeError.

Files

PathBytes
README.md1,424
impl/python.py2,577
impl/rust.rs2,949
impl/typescript.ts2,547
vectors.json1,794