Skip to content

Credit Card Validator — Python source

Validate credit card numbers with the Luhn checksum and detect the issuing brand (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay, Maestro). 100% client-side - nothing is transmitted.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""credit-card-validator — Python.

CosmoDev polyglot showcase port of the credit-card-validator tool,
ported from src/lib/creditCard.ts.

Display source — part of CosmoDev's polyglot tool pages.

Pure credit-card validation logic with no I/O: sanitize to digits, run the
Luhn checksum, detect the issuing brand by prefix + length, and group the
number for display. Never raises on malformed input.

NOTE on `\d`: Python's `re` module treats `\d` as Unicode decimal digits by
default, whereas JavaScript / Go / Rust treat it as ASCII [0-9]. To stay
byte-for-byte equivalent with the TypeScript source, every pattern here uses
explicit [0-9] ranges instead of \d / \D.
"""

from __future__ import annotations

import re
from dataclasses import dataclass


@dataclass
class CardInfo:
    """Full validation result. ``brand`` is None when the issuer is unknown."""

    input: str
    sanitized: str           # digits only
    valid: bool              # digits present, sane length, and Luhn passes
    luhn_valid: bool         # Luhn checksum passes (requires >= 12 digits)
    brand: str | None        # issuing brand, or None when unknown
    formatted: str           # grouped per brand spacing rules


def luhn_check(digits: str) -> bool:
    """Luhn (mod-10) checksum. Empty or non-digit input returns False."""
    if not digits or re.fullmatch(r"[0-9]*", digits) is None:
        return False
    total = 0
    should_double = False
    # Walk right-to-left; double every second digit and collapse >9 to its digit-sum
    # (e.g. 7*2=14 -> 14-9=5, identical to summing 1+4).
    for ch in reversed(digits):
        d = ord(ch) - 48  # ord("0") == 48
        if should_double:
            d *= 2
            if d > 9:
                d -= 9
        total += d
        should_double = not should_double
    return total % 10 == 0


# Brand detection table. ORDER MATTERS: the first prefix+length match wins.
# Maestro is last because its broad "6" / 5x ranges overlap narrower issuers.
_BRAND_RULES: list[tuple[str, re.Pattern[str], tuple[int, ...]]] = [
    ("Visa", re.compile(r"^4"), (13, 16, 19)),
    ("Mastercard", re.compile(r"^(5[1-5]|2(2[2-9]|[3-6][0-9]|7[01]|720))"), (16,)),
    ("American Express", re.compile(r"^3[47]"), (15,)),
    (
        "Discover",
        re.compile(r"^(6011|65|64[4-9]|622(12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9([01][0-9]|2[0-5])))"),
        (16, 19),
    ),
    ("Diners Club", re.compile(r"^(30[0-5]|3095|36|38|39|54|55)"), (14, 16)),
    ("JCB", re.compile(r"^35(2[89]|[3-8][0-9])"), (16, 17, 18, 19)),
    ("UnionPay", re.compile(r"^62"), (16, 17, 18, 19)),
    ("Maestro", re.compile(r"^(50|56|57|58|6)"), tuple(range(12, 20))),
]


def detect_brand(digits: str) -> str | None:
    """Detect the card brand by prefix + length. Returns None when unknown."""
    if not digits:
        return None
    for name, pattern, lengths in _BRAND_RULES:
        if pattern.search(digits) and len(digits) in lengths:
            return name
    return None


def _group(digits: str, widths: tuple[int, ...]) -> str:
    """Slice digits into runs of the given widths, joining with spaces."""
    parts: list[str] = []
    i = 0
    n = len(digits)
    for w in widths:
        if i >= n:
            break
        parts.append(digits[i : i + w])
        i += w
    # Any trailing remainder (e.g. a partially typed number) is appended as-is.
    if i < n:
        parts.append(digits[i:])
    return " ".join(parts)


def format_card(digits: str, brand: str | None) -> str:
    """Group the number per brand spacing rules."""
    if not digits:
        return ""
    # Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
    if brand == "American Express":
        return _group(digits, (4, 6, 5))
    if brand == "Diners Club" and len(digits) == 14:
        return _group(digits, (4, 6, 4))
    # Default: groups of four separated by spaces; the lookahead (?=.) drops the trailing space.
    return re.sub(r"(.{4})(?=.)", r"\1 ", digits).strip()


def validate_card(input: str) -> CardInfo:
    """Validate a card number. Always returns a CardInfo; never raises."""
    sanitized = re.sub(r"[^0-9]+", "", input)
    brand = detect_brand(sanitized)
    # Luhn is only meaningful once we have a plausible card length (>= 12 digits).
    luhn_valid = len(sanitized) >= 12 and luhn_check(sanitized)
    return CardInfo(
        input=input,
        sanitized=sanitized,
        valid=luhn_valid,
        luhn_valid=luhn_valid,
        brand=brand,
        formatted=format_card(sanitized, brand),
    )


if __name__ == "__main__":
    # Tiny showcase: validate a well-known test number (Visa).
    info = validate_card("4111 1111 1111 1111")
    print(f"brand={info.brand} valid={info.valid} formatted={info.formatted!r}")

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →