rexsum

Check digits, checked by the HTML pattern attribute

Every field below checks its check digit with nothing but a regular expression in its pattern attribute. No script runs: the browser's own form validation does the arithmetic, even with JavaScript turned off.

Try them

Each field holds a valid example. Change the payment card's last digit from 1 to 2 and the browser rejects it, using only the pattern attribute. A check digit catches any single wrong digit, but not every swap of neighbours: Luhn misses 09 ↔ 90, so 4009000000000003 and 4090000000000003 both pass. Download HTML gives a standalone page with no script, to open from disk.

24,471-character pattern Download HTML
24,466-character pattern Download HTML
23,814-character pattern Download HTML
91,659-character pattern Download HTML
12,262-character pattern Download HTML
3,971-character pattern Download HTML
16,199-character pattern Download HTML
10,022-character pattern Download HTML
10,379-character pattern Download HTML
53,594-character pattern Download HTML
59,966-character pattern Download HTML
178,241-character pattern Download HTML

How a regex does the arithmetic

A check-digit rule reads the digits and keeps a running sum modulo 10 or 11. That is a finite automaton, so an equivalent regular expression must exist. The textbook conversion makes it huge, and common advice says a regex cannot do this at all. Three steps bring it down to a size a form can carry.

37,396,808characters for Luhn without the split below

1. The direct route explodes

Converting an automaton to a regex removes its states one by one, and every removal can multiply the expression. Luhn's automaton has 20 states; even a one-line rule like "divisible by 11" comes out at 3,928,634 characters.

10 = 2 × 5so check mod 2 and mod 5 separately (Chinese remainder theorem)

2. Split the modulus

A sum is 0 mod 10 exactly when it is 0 mod 2 and 0 mod 5. A JavaScript lookahead intersects two regexes, (?=A$)B, so each part gets its own small automaton. For Luhn, the mod-2 part needs 198 characters; nearly all the size is in the mod-5 part.

24,246characters for Luhn's mod-5 part

3. Eliminate in the right order

The order in which states are removed decides the size. Removing the in-between positions first lets 5 states read two digits at a time, and the best of their 120 orders is kept. For short fixed lengths, splitting the number at the best position, recursively, does better: EAN-8 needs 3,971 characters.

The mod-2 part of Luhn, in full (the rightmost digit counts once, the next one doubled):

(?:[02468]|[13579](?:[0-4][02468]|[5-9][13579])*(?:[0-4][13579]|[5-9][02468]))?(?:(?:[0-4][13579]|[5-9][02468])(?:[0-4][02468]|[5-9][13579])*(?:[0-4][13579]|[5-9][02468])|[0-4][02468]|[5-9][13579])*

IBAN: too big for a pure regex

IBANs are checked mod 97. That is prime, so nothing splits, and the constructions above grow like n7.6 in the number of digits, and a German IBAN would need over 108 characters. No pure regex does much better: counting shows that every one needs at least 55 million. But a JavaScript regex has more syntax than a textbook regular expression. A group captured inside a lookahead keeps its value, and a backreference can test it later, so a group can serve as a register: capturing the first v characters stores the number v. Uezato used the same device in 2024 to prove exactly what such regexes can recognize (see below).

(?=(...))          stores 3: the group captures the first 3 characters
(?=\5(?<=^...))    tests whether group 5 holds 3

Each IBAN pattern is a row of such lookaheads. Each step reads one character and writes the new remainder into fresh groups, the way you would compute it on paper. The remainder is held as two small numbers, a ≤ 13 and b ≤ 9, standing for 10a + b and never reduced: since 100 leaves 3 mod 97, a digit d turns (a, b) into (b + ⌊(3a + d) / 10⌋, (3a + d) mod 10). A letter counts as two digits. The German pattern has 59,966 characters and 69 groups, and the size grows linearly with the length. The same machine checks LEIs, the 20-character codes that identify companies in financial reporting: their rule is the IBAN's without the reshuffle, and their pattern has 85,446 characters. One more pattern, of 178,241 characters, covers all 89 countries in the IBAN registry at once: it reads up to 33 characters and leaves the remainder alone past the end of a shorter IBAN. RF creditor references, the payment references on European invoices, use the IBAN check on as few as 5 characters, too few for registers that go up to 13; so each register is split in two, base 5, and the reference is read right-aligned, a shorter one reading as leading zeros. That pattern has 54,309 characters. A weighted sum needs only one register: a VIN's ninth character is a weighted sum mod 11 of the other sixteen, and adding a residue t is the register followed by t dots. Wrapping past 11 is a subtraction, which a backreference cannot do, so the step first captures the rest of the input and keeps the prefix that 11 − t more characters and that rest complete. The VIN pattern has 7,025 characters, where a pure regex needs at least 234,939. A modulus larger than the input takes two registers, a and b for Ka + b, and each step first tests whether the sum wraps. That is how a Spanish DNI's letter (mod 23), a Finnish personal identity code (mod 31), a Belgian structured communication and a French social security number (both mod 97) are checked, in 9,522, 11,494, 18,276 and 23,764 characters.

Backreferences make the syntax more expressive than regular expressions, but these patterns do not need that power: their strings have bounded length, so each language is finite and therefore regular. What the backreferences buy is size. A computed remainder is stored once and reused, where a pure regex has to spell out every path to it: 59,966 characters for Germany instead of at least 55 million.

Every pattern

Each pattern was tested against a reference implementation on 22,000 to 27,000 strings in Python, and on about 790 more, edge cases included, in the regex engines of Chromium (V8) and Safari (JavaScriptCore). The same strings were entered into a real <input> with JavaScript off in Chromium, Firefox and WebKit, and in Safari's own engine. No mismatches. Times are for V8 in Node with the v flag the pattern attribute uses. In a browser, the first keystroke compiles the pattern and later ones take at most about 3 ms; Safari compiles it again at every keystroke, in under 5 ms. Compressed, the patterns shrink 8 to 45 times: GitHub Pages sends this page, 548 kB with twelve fields, as 50 kB.

SchemeCharactersPartsConstructionCompile (ms)Match (µs)
Luhn, any length24,465 mod 2: 198
mod 5: 24,246
period-first elimination 80.6
Payment card number (Luhn, 12 to 19 digits)24,471 mod 2: 198
mod 5: 24,246
period-first elimination 60.4
Payment card number, 16 digits12,602 mod 2: 119
mod 5: 12,459
period-first elimination 30.6
IMEI (15 digits, Luhn)24,466 mod 2: 197
mod 5: 24,245
period-first elimination 71.4
Canadian SIN (9 digits, Luhn)10,379 mod 2: 197
mod 5: 10,159
interval splitting 210.2
GTIN check digit, any length23,806 mod 2: 35
mod 5: 23,750
period-first elimination 111.3
EAN-83,971 mod 2: 35
mod 5: 3,913
interval splitting 40.3
UPC-A (12 digits)12,262 mod 2: 35
mod 5: 12,203
period-first elimination 40.8
EAN-1323,808 mod 2: 35
mod 5: 23,749
period-first elimination 112.0
ISBN-1323,814 mod 2: 35
mod 5: 23,749
period-first elimination 111.2
GTIN-1412,262 mod 2: 35
mod 5: 12,203
period-first elimination 41.0
ABA routing number (9 digits)10,022 mod 2: 35
mod 5: 9,964
interval splitting 100.4
ISBN-1091,659 mod 11: 91,640interval splitting 873.3
ISSN (8 characters)16,199 mod 11: 16,180interval splitting 91.9
BSN (Dutch citizen service number)53,594 mod 11: 53,580interval splitting 412.2
IBAN, Germany59,966 mod 97: 59,966capture groups as registers 319.7
IBAN, Netherlands49,350 mod 97: 49,350capture groups as registers 237.9
IBAN, United Kingdom66,271 mod 97: 66,271capture groups as registers 3510.6
IBAN, France107,237 mod 97: 107,237capture groups as registers 6217.8
IBAN, any country178,241 mod 97: 178,241capture groups as registers 14721.5
LEI (Legal Entity Identifier)85,446 mod 97: 85,446capture groups as registers 4213.9
RF creditor reference (ISO 11649)54,309 mod 97: 54,309capture groups as registers 1918.3
VIN (Vehicle Identification Number)7,025 mod 11: 7,025capture groups as registers 33.7
DNI or NIE (Spanish identity number)9,522 mod 23: 9,522capture groups as registers 21.8
HETU (Finnish personal identity code)11,494 mod 31: 11,494capture groups as registers 32.2
Belgian structured communication (OGM-VCS)18,276 mod 97: 18,276capture groups as registers 63.3
NIR (French social security number)23,764 mod 97: 23,764capture groups as registers 85.5

Limits

A prime modulus cannot be split, and the size grows fast with it. Digits whose sum is a multiple of 5 already need stars nested five deep (McNaughton's theorem on star height), and the constructions here grow like 4q−1 for modulus q.

Mod 97 as a pure regex

Eight-digit multiples of 97 already need 100,133 characters, and any pure regex for a German IBAN needs at least 55 million (the README has the proof). The IBAN patterns here use capture groups instead, and accept only the electronic form: capitals, no spaces.

Damm check digit

Its table is a quasigroup, not a group, so nothing splits. The direct conversion gives 1,398,101 characters. Capture registers should handle it as they handle IBAN; not tried yet.

ISBN-10 is the largest pure regex

Mod 11 is prime, so its pattern is one piece of 91,659 characters. The first keystroke compiles it: about 0.2 seconds in Chromium on GitHub's Linux runners.

What existed before