rexsum
pattern attributeEvery 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.
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.
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
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)
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
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])*
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.
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.
| Scheme | Characters | Parts | Construction | Compile (ms) | Match (µs) |
|---|---|---|---|---|---|
| Luhn, any length | 24,465 | mod 2: 198 mod 5: 24,246 | period-first elimination | 8 | 0.6 |
| Payment card number (Luhn, 12 to 19 digits) | 24,471 | mod 2: 198 mod 5: 24,246 | period-first elimination | 6 | 0.4 |
| Payment card number, 16 digits | 12,602 | mod 2: 119 mod 5: 12,459 | period-first elimination | 3 | 0.6 |
| IMEI (15 digits, Luhn) | 24,466 | mod 2: 197 mod 5: 24,245 | period-first elimination | 7 | 1.4 |
| Canadian SIN (9 digits, Luhn) | 10,379 | mod 2: 197 mod 5: 10,159 | interval splitting | 21 | 0.2 |
| GTIN check digit, any length | 23,806 | mod 2: 35 mod 5: 23,750 | period-first elimination | 11 | 1.3 |
| EAN-8 | 3,971 | mod 2: 35 mod 5: 3,913 | interval splitting | 4 | 0.3 |
| UPC-A (12 digits) | 12,262 | mod 2: 35 mod 5: 12,203 | period-first elimination | 4 | 0.8 |
| EAN-13 | 23,808 | mod 2: 35 mod 5: 23,749 | period-first elimination | 11 | 2.0 |
| ISBN-13 | 23,814 | mod 2: 35 mod 5: 23,749 | period-first elimination | 11 | 1.2 |
| GTIN-14 | 12,262 | mod 2: 35 mod 5: 12,203 | period-first elimination | 4 | 1.0 |
| ABA routing number (9 digits) | 10,022 | mod 2: 35 mod 5: 9,964 | interval splitting | 10 | 0.4 |
| ISBN-10 | 91,659 | mod 11: 91,640 | interval splitting | 87 | 3.3 |
| ISSN (8 characters) | 16,199 | mod 11: 16,180 | interval splitting | 9 | 1.9 |
| BSN (Dutch citizen service number) | 53,594 | mod 11: 53,580 | interval splitting | 41 | 2.2 |
| IBAN, Germany | 59,966 | mod 97: 59,966 | capture groups as registers | 31 | 9.7 |
| IBAN, Netherlands | 49,350 | mod 97: 49,350 | capture groups as registers | 23 | 7.9 |
| IBAN, United Kingdom | 66,271 | mod 97: 66,271 | capture groups as registers | 35 | 10.6 |
| IBAN, France | 107,237 | mod 97: 107,237 | capture groups as registers | 62 | 17.8 |
| IBAN, any country | 178,241 | mod 97: 178,241 | capture groups as registers | 147 | 21.5 |
| LEI (Legal Entity Identifier) | 85,446 | mod 97: 85,446 | capture groups as registers | 42 | 13.9 |
| RF creditor reference (ISO 11649) | 54,309 | mod 97: 54,309 | capture groups as registers | 19 | 18.3 |
| VIN (Vehicle Identification Number) | 7,025 | mod 11: 7,025 | capture groups as registers | 3 | 3.7 |
| DNI or NIE (Spanish identity number) | 9,522 | mod 23: 9,522 | capture groups as registers | 2 | 1.8 |
| HETU (Finnish personal identity code) | 11,494 | mod 31: 11,494 | capture groups as registers | 3 | 2.2 |
| Belgian structured communication (OGM-VCS) | 18,276 | mod 97: 18,276 | capture groups as registers | 6 | 3.3 |
| NIR (French social security number) | 23,764 | mod 97: 23,764 | capture groups as registers | 8 | 5.5 |
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.
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.
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.
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.