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.

Loading the compiler…

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 forI-RegexpModule sizeWith prefer_tableDoes 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 KBSameVersion 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 B1.1 KB—
URL slug[a-z0-9]+(-[a-z0-9]+)*487 B956 BLength
Hex color#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})773 B980 B—
ISO 8601 calendar date[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])715 B1.1 KBDays 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 KBSame—
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 KBSame253-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 KBSameWhether the mailbox exists
Kubernetes label name (DNS-1123 label)[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?1.7 KBSame—
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 B963 B253-character total length
Environment variable name[A-Za-z_][A-Za-z0-9_]*504 B956 B—
Git object ID (SHA-1 or SHA-256)[0-9a-f]{40}([0-9a-f]{24})?1.1 KBSameThat the object exists
Docker image tag[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}1.7 KBSame—
Unicode username\p{L}(\p{L}|\p{Nd}|[_.-])*21.5 KB147 KBLength, 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:

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.

Loading the compiler…

What the numbers show

In the recorded Chrome run, the input format decides most of the result:

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.

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 #

See text components for other validators, such as BCP 47 language tags.