Credit Card Validation in Python — Luhn Algorithm Explained
How the Luhn algorithm works, why a DIY implementation isn't enough for production, and how to detect the card network — all in one API call from Python.
In this guide
1. What is the Luhn algorithm?
The Luhn algorithm (also called Luhn formula or mod-10 algorithm) is a simple checksum formula invented by IBM scientist Hans Peter Luhn in 1954. It was designed to protect against accidental errors — not malicious attacks — when entering numbers by hand.
Today, Luhn validation is used by virtually every credit card network (Visa, Mastercard, Amex, Discover, etc.), as well as IMEI numbers, Canadian Social Insurance Numbers, and more. When a user mistypes a single digit in a card number, Luhn will almost certainly catch it — saving a round-trip to the payment processor.
2. Decode Luhn step by step — using 4111111111111111
Let's walk through the algorithm using the well-known Visa test card number: 4111 1111 1111 1111.
Step 1 — Remove spaces, work right to left
Strip all non-digit characters. Starting from the second-to-last digit and moving left, double every second digit.
| Position (RTL) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Digit | 4 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| Doubled | 4 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 |
Blue = doubled (every 2nd digit from right, starting at position 2)
Step 2 — Subtract 9 from any doubled result > 9
For example, doubling 6 gives 12 → subtract 9 → 3. In this example all doubled digits are 1→2, so no adjustment needed.
Step 3 — Sum all digits
4 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 = 28... wait, that's the example simplified. Let's use a realistic card:
For 4111111111111111 the sum is 40.
Step 4 — Check divisibility by 10
If total % 10 == 0 the number is Luhn-valid. For our test card: 40 % 10 = 0 ✓
3. DIY Python implementation
The algorithm itself is compact. Here's a correct, idiomatic Python implementation:
# luhn.py — correct implementation of the Luhn algorithm def luhn_check(number: str) -> bool: """Return True if the card number passes the Luhn mod-10 checksum.""" digits = [int(d) for d in number if d.isdigit()] if len(digits) < 13 or len(digits) > 19: return False odd_digits = digits[-1::-2] even_digits = digits[-2::-2] total = sum(odd_digits) for d in even_digits: total += sum(divmod(d * 2, 10)) return total % 10 == 0 # Test cards (all should pass) print(luhn_check("4111 1111 1111 1111")) # True — Visa print(luhn_check("5500 0000 0000 0004")) # True — Mastercard print(luhn_check("3714 496353 98431")) # True — Amex print(luhn_check("4111 1111 1111 1112")) # False — one digit off
divmod(d * 2, 10) trick splits a two-digit number into its tens and units — e.g. divmod(14, 10) returns (1, 4), and summing those gives 5. This is equivalent to subtracting 9 from any doubled result greater than 9.4. Why Luhn alone isn't enough
The function above works correctly. So why not just use it? Because real-world credit card validation involves several additional layers:
Length validation per network
Valid card numbers are 13–19 digits, but the exact length depends on the network. Visa uses 13 or 16 digits. Amex always has 15. Mastercard is always 16. JCB can be 16–19. A 14-digit number that passes Luhn cannot be a valid Visa card.
Network detection (IIN/BIN ranges)
To show the right card logo in your UI, route to the correct processor, or apply network-specific fee rules, you need to identify the card network. This requires matching the first 1–8 digits (the Issuer Identification Number) against a list of BIN ranges — which is considerably more complex than a single regex.
New BIN ranges and co-branded cards
Mastercard expanded its BIN range from 51–55 to include 2221–2720 in 2017. Any hardcoded table from before that date silently rejects newer Mastercard numbers. Co-branded cards (e.g. Visa/Electron, Mastercard/Maestro) add further complexity — the same BIN prefix may be valid on multiple networks.
Virtual cards and tokenised PANs
Virtual card numbers (Apple Pay, Google Pay, bank-issued virtual cards) use valid BIN ranges that differ from their physical counterparts. A DIY BIN table quickly goes stale as issuers roll out new virtual card programs.
Test card numbers
Payment processors publish official test card numbers that pass Luhn but should never be accepted in production. Your server-side validator should know about them if you're doing pre-processing checks outside of a payment SDK.
# These all pass Luhn — they are test cards, not real ones: # 4111 1111 1111 1111 — Visa test # 5500 0000 0000 0004 — Mastercard test # 3714 4963 5398 431 — Amex test # 6011 1111 1111 1117 — Discover test
5. Network detection (BIN ranges)
Each card network owns a set of IIN (Issuer Identification Number) prefixes. The first digits of a card number uniquely identify the network:
| Network | Starts with | Length |
|---|---|---|
| Visa | 4 | 13 or 16 |
| Mastercard | 51–55, 2221–2720 | 16 |
| American Express | 34, 37 | 15 |
| Discover | 6011, 622126–622925, 644–649, 65 | 16 |
| JCB | 3528–3589 | 16–19 |
| Diners Club | 300–305, 36, 38 | 14 |
| UnionPay | 62, 81 | 16–19 |
Maintaining this table correctly is surprisingly tricky — Mastercard expanded its BIN range from the 51–55 prefix to include 2221–2720 in 2017. Any hardcoded regex from before that date will silently reject newer Mastercard numbers.
6. The production-ready solution
The IsValid Credit Card API handles Luhn check, network detection, and length validation in a single POST request. The number is sent in the body — not in the URL — so it never appears in access logs.
Get your free API key at isvalid.dev — 100 calls per day, no credit card required.
Full parameter reference and response schema: Credit Card API docs →
7. Python code example
Using the isvalid-sdk Python SDK or the popular requests library. Install either with pip install isvalid-sdk or pip install requests.
# card_validator.py import os from isvalid_sdk import IsValidConfig, create_client iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"])) result = iv.credit_card("4111111111111111") if not result["valid"]: print("Invalid card number") else: print(f"Network: {result['network']}") # "Visa" print(f"Luhn: {result['luhn']}") # True print(f"Valid {result['network']} card ({result['length']} digits)") # → "Valid Visa card (16 digits)"
In a Django or Flask checkout handler you might use it like this:
# views.py (Django / Flask pattern) from django.http import JsonResponse import requests as http def checkout(request): card_number = request.POST.get("cardNumber", "").strip() if not card_number: return JsonResponse({"error": "card number required"}, status=400) try: card_check = validate_card(card_number) except http.Timeout: return JsonResponse({"error": "Card validation service timeout"}, status=502) except http.HTTPError as exc: return JsonResponse({"error": str(exc)}, status=502) if not card_check["valid"]: return JsonResponse({"error": "Invalid card number"}, status=400) # Proceed to payment processor — never store the raw card number charge = payment_processor.charge( network=card_check["network"], # pass tokenised number to your PSP, not the raw one ) return JsonResponse({"success": True, "chargeId": charge["id"]})
Zero-dependency alternative using only the standard library:
# card_validator_stdlib.py — no third-party packages import json import os import urllib.error import urllib.request API_KEY = os.environ["ISVALID_API_KEY"] BASE_URL = "https://api.isvalid.dev" def validate_card(number: str) -> dict: body = json.dumps({"number": number}).encode() req = urllib.request.Request( f"{BASE_URL}/v0/credit-card", data=body, headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=5) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as exc: raise RuntimeError(f"API error {exc.code}: {exc.read().decode()}") from exc result = validate_card("4111111111111111") print(result["valid"], result["network"]) # True Visa
8. cURL example
Note the -X POST and Content-Type header — this endpoint only accepts POST to keep the card number out of server logs.
curl -X POST \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"number": "4111111111111111"}' \ "https://api.isvalid.dev/v0/credit-card"
Amex test card (15 digits):
curl -X POST \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"number": "371449635398431"}' \ "https://api.isvalid.dev/v0/credit-card"
Invalid number (fails Luhn):
curl -X POST \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"number": "4111111111111112"}' \ "https://api.isvalid.dev/v0/credit-card"
9. Understanding the response
Valid Visa test card:
{ "valid": true, "network": "Visa", "length": 16, "luhn": true }
Invalid number (bad checksum):
{ "valid": false, "network": "Visa", "length": 16, "luhn": false }
Accessing fields in Python:
result = validate_card("4111111111111111") # Boolean checks if result["valid"] and result["luhn"]: print(f"Card is valid on {result['network']} network") # Dict unpacking for structured logging print( "card_check " f"valid={result['valid']} " f"network={result['network']} " f"length={result['length']} " f"luhn={result['luhn']}" )
| Field | Type | Description |
|---|---|---|
| valid | boolean | true only when Luhn passes and the length is correct for the detected network |
| network | string | Detected card network: Visa, Mastercard, Amex, Discover, JCB, Diners Club, UnionPay, or Unknown |
| length | number | Number of digits after stripping non-numeric characters |
| luhn | boolean | Whether the number passes the Luhn mod-10 checksum (independently of length) |
Note: valid is the combined result of Luhn + length. luhn lets you distinguish between a bad checksum vs. correct checksum but wrong length for the network.
10. Security and edge cases
Input formatting — accept any separator
Users enter card numbers in many formats: 4111-1111-1111-1111, 4111 1111 1111 1111, 4111111111111111. The API strips all non-digit characters automatically — pass the raw user input.
Client-side Luhn pre-check
Run Luhn validation before hitting the API. This gives instant feedback without a network round-trip — and avoids wasting API calls on obviously invalid numbers.
# Pre-check before calling the API def luhn_check(number: str) -> bool: digits = [int(d) for d in number if d.isdigit()] odd_digits = digits[-1::-2] even_digits = digits[-2::-2] total = sum(odd_digits) for d in even_digits: total += sum(divmod(d * 2, 10)) return total % 10 == 0 card_input = input("Card number: ").strip() if not luhn_check(card_input): print("Invalid card number — please check and try again") else: result = validate_card(card_input) print(f"{result['network']} — {'valid' if result['valid'] else 'invalid'}")
Test card numbers
These numbers are published by payment processors for testing. They all pass Luhn but do not represent real accounts:
TEST_CARDS = { "4111111111111111": "Visa", "5500000000000004": "Mastercard", "371449635398431": "Amex", "6011111111111117": "Discover", "3530111333300000": "JCB", "30569309025904": "Diners Club", } for number, network in TEST_CARDS.items(): result = validate_card(number) assert result["valid"], f"{network} test card should be valid" assert result["network"] == network, f"Expected {network}, got {result['network']}"
Never log or store raw card numbers
PCI-DSS prohibits storing the full PAN (Primary Account Number) unencrypted. Use your payment processor's tokenisation — pass the token, not the number, to your backend. The IsValid API is for format pre-validation only; the actual charge flow must go through a compliant PSP.
Virtual and co-branded cards
Apple Pay, Google Pay, and bank-issued virtual cards use valid BIN ranges that may differ from their physical counterparts. The API's BIN database is updated regularly, so you don't need to track these changes yourself.
Summary
Python integration notes
Pydantic V2 makes it straightforward to validate Credit Card Number as part of a data model. Use @field_validator or the Annotated pattern with AfterValidator to call the IsValid API inside the validator and raise aValueError on failure. The validator runs automatically whenever the model is instantiated — in a FastAPI request body, a SQLModel ORM model, or a standalone Pydantic parse. Initialise the API client as a module-level singleton so it is not re-created on every request.
FastAPI and Django integration
In FastAPI, inject the IsValid client viaDepends() so the samehttpx.AsyncClient connection pool is shared across all concurrent requests. Pair it with async route handlers to keep the event loop non-blocking during Credit Card Number validation. In Django, implement aclean() method on your model or form field to call the synchronous SDK client; wrap it insync_to_async() if you are using Django Channels or async views.
When processing a batch of Credit Card Number values — during a CSV import or a nightly reconciliation job — useasyncio.gather() with a shared httpx.AsyncClientand an asyncio.Semaphoreto cap concurrency at the API rate limit. This brings total validation time from O(n) sequential to O(1) bounded by the pool size.
Handle httpx.HTTPStatusError andhttpx.RequestErrorseparately. A 422 from IsValid means the Credit Card Number is structurally invalid — surface this as a validation error to the user. A 503 or network error means the API is temporarily unavailable — retry with exponential backoff usingtenacity before falling back gracefully.
- Store
ISVALID_API_KEYin a.envfile and load it withpython-dotenvat startup - Use
pytest-asynciowithasyncio_mode = "auto"for testing async validation paths - Type-annotate validated fields with
NewType('CreditCardNumber', str)for clarity in function signatures - Apply
.strip()and.upper()before validation — Credit Card Number values often arrive with stray whitespace or mixed case from user input
See also
Validate credit cards instantly
Free tier includes 100 API calls per day. No credit card required. Supports Visa, Mastercard, Amex, Discover, JCB, Diners Club, and UnionPay.