Regular expression validators
Type an RFC 9485 I-Regexp pattern
and get a WebAssembly validator for it. The validator accepts text unchanged
when the pattern matches all of it. Otherwise it reports the offset where
matching stopped. Everything runs in this page with
i-regexp-to-wasm.wasm.
The pattern must match the whole text, so you do not write ^ or $ anchors.
In I-Regexp, ^ and $ are ordinary characters. A dot matches any character
except \n and \r. I-Regexp has no capture groups, lookaround,
backreferences, lazy quantifiers or flags, and it has no shorthand classes
such as \d. Write [0-9] instead.
Ready-made patterns #
These patterns check the shape of common identifiers. Pick one in the menu
above to try it, then download the validator. Sizes are for the generated
.wasm module, first with the default settings and then with
prefer_table=1.
| Pattern for | I-Regexp | Module size | With prefer_table | Does not check |
| UUID | [0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} | 1.1 KB | Same | Version and variant bits |
| Semantic version 2.0.0 | (0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)? | 995 B | 1.1 KB | — |
| URL slug | [a-z0-9]+(-[a-z0-9]+)* | 487 B | 956 B | Length |
| Hex color | #([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8}) | 773 B | 980 B | — |
| ISO 8601 calendar date | [0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) | 715 B | 1.1 KB | Days per month, so 2025-02-31 passes |
| IPv4 address | ((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]) | 1.1 KB | Same | — |
| Hostname (RFC 1123) | [a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)* | 2.0 KB | Same | 253-character total length |
Email address (HTML type=email) | [A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)* | 2.5 KB | Same | Whether the mailbox exists |
| Kubernetes label name (DNS-1123 label) | [a-z0-9]([-a-z0-9]{0,61}[a-z0-9])? | 1.7 KB | Same | — |
| Kubernetes resource name (DNS-1123 subdomain) | [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* | 534 B | 963 B | 253-character total length |
| Environment variable name | [A-Za-z_][A-Za-z0-9_]* | 504 B | 956 B | — |
| Git object ID (SHA-1 or SHA-256) | [0-9a-f]{40}([0-9a-f]{24})? | 1.1 KB | Same | That the object exists |
| Docker image tag | [a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127} | 1.7 KB | Same | — |
| Unicode username | \p{L}(\p{L}|\p{Nd}|[_.-])* | 21.5 KB | 147 KB | Length, confusable characters |
The HTML email and Kubernetes patterns are the ones those specifications
publish. The HTML pattern accepts a@b and rejects quoted local parts and
internationalized domain names, as browsers do.
The Unicode username module is 10 to 40 times the size of the others, and
147 KB as a table. The validator works on UTF-8 bytes, so it needs states for
the partly read bytes of every letter and digit in Unicode 17. Use \p{..}
only when you need it.
Speed compared with the browser's RegExp #
The benchmark checks the same text with the generated validator and with the
browser's built-in RegExp. Each I-Regexp becomes new RegExp("^(?:…)$", "u")
with the RFC 9485 §5.3
mapping. Before it times anything, the page confirms that both engines agree
on every sample. It compiles every validator with prefer_table=1, the
encoding the recorded results were measured with.
It has three parts:
- Short strings are form-field values: each call checks one value from the
ready-made patterns' samples. "String in" includes copying the JavaScript
string into WebAssembly memory as UTF-8 with
TextEncoder.encodeInto.
"Bytes in" starts from UTF-8 bytes, such as a request body, and copies them
in with Uint8Array.set.
- Long text checks 1 MiB inputs and reports megabytes per second of UTF-8.
- Backtracking checks
([a-z0-9]+-?)*[a-z0-9] against aaa…a! of
growing length. A backtracking engine tries each way of splitting the as
between the two loops before it fails, so its time doubles with about every
extra character. The generated validator reads each byte once.
Recorded results
One run in headless Chrome 156 on an Apple M5. Each value is the median of five rounds. In a second run, short-string and long-text values moved by up to 17%, and the order of the columns did not change.
Run the benchmark
The benchmark takes about 20 seconds. The backtracking part runs RegExp in
a worker and stops it after 2 seconds per input.
What the numbers show
In the recorded Chrome run, the input format decides most of the result:
- From a JavaScript string,
RegExp is faster. Copying the string into
WebAssembly memory with encodeInto costs about 55 ns per call. That is
more than a whole short match takes in either engine. If you validate form
fields in a browser with trusted patterns, use RegExp.
- From UTF-8 bytes, the validator is level or faster. A call costs 13 to
25 ns. That includes the JavaScript-to-Wasm call and the
BigInt for the
64-bit result. RegExp is level on simple patterns and faster on the
environment-variable name. It is 1.5 to 2 times slower on patterns with
counted repeats, such as the UUID's {8}, the hex color and the Git object
ID.
- On long text the validator does one table lookup and one branch per
byte, at about 3 GB/s for ASCII patterns.
RegExp is level on lowercase
words and 1.6 times slower on Kubernetes names. It is 4.5 times slower on
Base64, where a counted group sits inside a loop, and 4 times slower on
Unicode categories. From a JavaScript string the
validator spends about half of its time on the UTF-8 copy.
Backtracking is the largest difference. A RegExp that rejects a short
string in microseconds takes more than a second on 29 characters, and each
extra character doubles the time. In the main thread, that call freezes the
page (a ReDoS). The generated validator has no slow input, because it reads
every byte at most once, whatever the pattern. So you can compile patterns
that users or configuration files supply.
The compiler avoids backtracking by doing the work before any text arrives.
It turns the pattern into a deterministic state machine (a DFA). This is a
table with one row per state and one column per group of similar bytes. A
state stands for every way the pattern could still match at once. So for each
byte the table gives exactly one next state, and there is never a second
choice to come back and try. The validator does one table lookup per byte and
stops at the first byte that no match can continue through. Its time grows
in step with the length of the text, for every pattern.
The cost moves to compile time, because some patterns need a very large
table. (a|b)*a(a|b){20} has to remember the last 21 characters, which takes
about two million states. The compiler stops at 30,000 states or a 4 MiB
table and reports the pattern as too large. So neither compiling a pattern
nor running its validator can blow up.
Other browsers have different regular expression engines, so run the
benchmark in the browsers you support.
Smaller code or a table #
The compiler can write the state machine into the module in two ways. As a
table, render looks up each byte's group in a 256-byte map and then looks
up the next state in the table. As code, render jumps to the current state's
code with br_table. That code compares the byte with the ranges that state
accepts, using select rather than branches. Both run the same minimized
state machine, so they accept the same text and reject at the same offsets.
By default the compiler measures both modules and writes the smaller one, so
7 of the 14 ready-made patterns compile to code. Patterns with long counted
repeats, such as the hostname's {0,61}, need one state per position, and
there the table is smaller. Set the prefer_table uniform to always get a
table:
printf '[a-z0-9]+(-[a-z0-9]+)*' | npx qipdo qip.dev -o slug.wasm text/i-regexp-to-wasm.wasm -u prefer_table=1
The smaller module is often the slower one. In Node 26 on an Apple M5, with
UTF-8 bytes in:
| | Code (default) | Table (prefer_table=1) |
|---|---|---|
| URL slug, my-first-blog-post | 32 ns, 487 B | 21 ns, 956 B |
| Unicode username, Zoë_Ångström.42 | 96 ns, 21.5 KB | 21 ns, 147 KB |
| 1 MiB of lowercase words | 1.9 GB/s, 445 B | 3.5 GB/s, 951 B |
| 1 MiB of Base64 | 1.0 GB/s, 649 B | 3.5 GB/s, 968 B |
| 1 MiB of Unicode letters and spaces | 269 MB/s, 15.4 KB | 676 MB/s, 58.3 KB |
Each value is the best of several rounds. The code jumps through br_table
on every byte and evaluates every range test of the current state. The table
does two loads per byte, whatever the pattern. Use the default for modules
that you ship to browsers or store in many places. Set prefer_table=1 for
validators that check a lot of text. The name describes what it does, not a
speed promise: a large table can miss the CPU cache, so measure on the
machines you care about.
Compared with a hand-written validator #
validate-hostname.wasm is a hand-written
RFC 1123 hostname validator, written in WAT. Its labels follow the same rules
as the hostname preset, and it also enforces the 253-character limit, which
the pattern cannot express. For inputs of up to 252 bytes it gives the same
accept and reject results as the generated module, including offsets. A test
checks this on more than 20,000 random names. Both modules pass the strict
profile, loop and output-size checks.
| | Hand-written | Generated from the preset |
|---|---|---|
| Module size | 397 B | 1,956 B |
| localhost (9 B) | 11 ns | 12–13 ns |
| api.example.com (15 B) | 18 ns | 17 ns |
| Kubernetes service name (52 B) | 39–41 ns | 35–37 ns |
| 253-byte name of 63-letter labels | 149–150 ns | 272–307 ns |
These are bytes-in times per call in Node 26 on an Apple M5, including the
copy into Wasm memory. They are the range of two runs, each the median of 7.
- Size: the generated module is 5 times larger. Nearly all of the 2 KB is the
256-byte class map and the transition table. The
{0,61} repeat unrolls
into one state per label position. The hand-written module keeps a label
counter instead and compares byte ranges in code.
- Speed: on real hostnames the two are level. Below about 50 bytes, the
call and the copy take most of the time. The hand-written module is about 2
times faster on the 253-byte name. Its branches are easy to predict there,
because every label is the same letter repeated. The table lookup must wait
for the previous lookup to load, so it costs about 1 ns per byte whatever
the text.
Write a validator by hand when you need a rule that a pattern cannot express,
such as a total length limit, or when module size matters. Use the compiler
for everything else: you get a checked module in one command, with the same
rejection offsets.
Use a validator outside the browser #
Compile a pattern with npx qipdo, then run the validator on text. The
second command prints en-gb, because it matches. The third fails and reports
input offset 3, where G breaks the pattern.
printf '[a-z]{2,8}(-[a-z0-9]+)*' | npx qipdo qip.dev -o lang.wasm text/i-regexp-to-wasm.wasm
printf 'en-gb' | npx qipdo lang.wasm
printf 'en-GB' | npx qipdo lang.wasm
The same lang.wasm runs in any QIP host: the browser, Node, Go with
wazero, Rust with qipx, and Swift. Each host gets the same accept and reject
results, including offsets. A JavaScript RegExp, a Go regexp and a
Python re can disagree on the same pattern. RFC 9485 exists to give one
pattern one meaning.
Add -u prefer_table=1 after the compiler to always get a transition table
(see above).
When not to use this #
- To search, extract, or replace text. A validator answers whether the
whole text matches. It does not find matches inside text or return groups.
- For short trusted patterns in JavaScript.
RegExp is faster per call
and needs no extra file.
- For patterns that need lookaround or backreferences. I-Regexp leaves
them out on purpose, because they need backtracking.
- For patterns that need a huge table. The compiler rejects patterns
whose table needs more than 30,000 states or 4 MiB, such as
(a|b)*a(a|b){20} (see above), and large counted repeats such as a bounded
\p{L}{0,31}. It reports the failure as "too large" and gives the offset.
- For checks that are not about shape. A pattern cannot check that a date
exists, that a checksum is correct, or that a hostname is at most 253
characters long. Combine the validator with a component made for that
check, such as
luhn.wasm for card numbers, or use
one that does both, such as
validate-hostname.wasm.
See text components for other validators, such as
BCP 47 language tags.