Earlier today I wrote about a WAL-index bug in go-sqlite3, the SQLite driver that compiles SQLite to WebAssembly and translates the module into Go with wasm2go. I run it in production. The obvious next question is whether the same trick works for PCRE2. Go’s regexp package is RE2, so it has no backreferences and no lookaround, and a lot of rule sets in the wild (ModSecurity rules, old Perl tooling) were written with PCRE in mind.
The trick would work. I still wouldn’t ship it today, and most of my reasons have nothing to do with wasm2go.
Update, the same evening: I built the library this post ends by asking for: franchb/go-pcre2. Benchmarks, and what I got wrong on the way, are in the last section. The next morning I built both items on its roadmap, first decided not to merge them, then did; the details. Fuzzing came after that.
Table of contents
Open Table of contents
I had the wasm2go part wrong
I started drafting this post in April. Back then wasm2go looked like a side project: one maintainer, one serious consumer (his own go-sqlite3), very few tags. The draft said it wasn’t production material and recommended running PCRE2 as a .wasm blob under wazero instead, the way wasilibs/go-re2 did.
Six months later the go-re2 history alone undoes that argument:
- #234, May 20: adds a wasm2go backend.
- #251, July 8: makes wasm2go the default (released in v1.12.0).
- #276, September 25: retires the wazero backend.
The current go.mod of go-re2 requires only golang.org/x/sys. The best-known example of “C library in Go via Wasm and wazero” no longer uses wazero.
wasm2go itself is at v0.4.19, and its USERS.md lists 21 libraries, among them bindings for libgit2, tree-sitter, HarfBuzz’s subsetter, libavif and MicroPython. It is still one maintainer, which matters, but it is no longer one consumer.
The sandbox survives the translation
The other argument in my April draft was that translating Wasm to Go throws away the sandbox, so a memory bug in PCRE2 would become a memory bug in your process. That was wrong too, and wasm2go’s SECURITY.md explains why. Linear memory is a single Go slice, and every load and store is bounds-checked against it. An out-of-range access panics. The call stack belongs to the Go runtime and can’t be addressed from linear memory, and the code lives outside it. The README adds that even the optional -unsafe mode follows the unsafe.Pointer rules and keeps the bounds checks.
What the sandbox doesn’t prevent is a bug in PCRE2 corrupting PCRE2’s own heap inside linear memory. That gives you wrong match results or a panic, never a write into Go memory. wazero has exactly the same property, so it isn’t a reason to prefer one runtime over the other. The practical difference is that a trap shows up as a Go panic, so a wrapper has to recover at its API boundary and return an error.
The project frames this with Chromium’s Rule of Two: pick at most two of an unsafe implementation language, untrustworthy input and high privilege. A regex engine in a WAF is C code reading attacker-controlled bytes, so it already has two. Running it inside a bounds-checked slice is the point. One caveat from the README: wasm2go assumes the Wasm module itself is trusted. That’s fine here, because you build the module. Patterns and subjects are data.
So on the runtime question, wasm2go is good enough. The costs that remain are about PCRE2.
What PCRE2 gives you that regexp2 doesn’t
dlclark/regexp2 is a pure-Go port of the .NET regex engine, now at v2.11.0. Its README has a full comparison table. Next to Go’s regexp it adds lookahead, lookbehind, backreferences (numbered and named), atomic groups, possessive quantifiers, conditionals, \R and \X.
Here is what it lacks next to PCRE2. I checked these against the parser source: the group parser in syntax/parser.go accepts :, =, !, >, ', <, ( and P after (?, and nothing else.
| PCRE2 feature | Syntax | regexp2 |
|---|---|---|
| Reset match start | \K | no |
| Whole-pattern recursion | (?R) | no |
| Subroutine calls | (?1), (?&name) | no |
| Callouts | (?C1) | no |
| Branch reset | (?|a|b) | no |
If your patterns don’t use any of these five, you don’t need PCRE2. Grep your rule set before you decide.
What it would cost to build
Nobody maintains the build
There is no wasilibs/go-pcre2, and nothing PCRE-shaped in wasm2go’s users list (which calls itself incomplete, so the honest statement is that I couldn’t find one). The closest prior attempt is bobby-stripe/go-pcre. Its last tag is v1.0.1 from 2021, it bundles PCRE2 10.38 (PCRE2_DATE 2021-10-01 in its header), and it runs on an older Wasm interpreter called gasm, not wazero or wasm2go.
So you would own the pipeline: PCRE2 C source, a wasi-sdk build with the JIT disabled, a wasm2go run, and a Go API on top that copies subjects in and match offsets out. PCRE2 shipped 10.47 on 2025-10-21, 10.48 on 2026-08-31 and 10.49 on 2026-09-28. Its SUPPORT-LIFECYCLE.md promises at least five years of security backports per release, published as release/pcre2-10.xx branches. Staying current means rebuilding and re-translating on every one of those. That’s ordinary work, but it’s work you’d do alone.
Use PCRE2, not PCRE1. The PCRE2 README says plainly that “the original PCRE1 library is now obsolete and no longer maintained.” One detail helps the Wasm build: since 10.30 (August 2017) the interpreter no longer uses recursive function calls to remember backtracking positions, so deep backtracking costs heap rather than the small Wasm stack.
The JIT doesn’t come along
pcre2jit(3) lists the platforms the JIT supports: 32- and 64-bit ARM, IBM s390x, x86 and x86-64, LoongArch, MIPS, PowerPC and RISC-V. WebAssembly isn’t on the list, and the same page says configuring with --enable-jit on an unsupported platform fails the build. A Wasm module also has no way to generate machine code and jump to it. So the interpreter is all you get.
The docs only say the JIT “can greatly speed up pattern matching”. I haven’t measured the gap on a realistic rule set, and the figures I’ve seen quoted vary too much to repeat here. If you’re considering this path, measure first with a native build: pcre2test -t against pcre2test -jit -t on your own patterns and traffic samples. The JIT is a big part of why people pick PCRE2 for WAF-style workloads, so losing it may cancel most of the reason to want PCRE2.
Backtracking on hostile input
PCRE2 and regexp2 are both backtracking engines, so both can take exponential time on the wrong pattern and input. The runtime doesn’t change that. Both offer limits:
- PCRE2:
pcre2_set_match_limit,pcre2_set_depth_limitandpcre2_set_heap_limiton the match context. - regexp2: a backtracking stack capped at 100,000 slots by default (
OptionMaxBacktrackingStackSize), plusMatchTimeout. The README notes that the stack cap bounds memory, not time, so set the timeout as well. It costs a background clock that ticks about every 100 ms, at about 0.15% CPU while any timed match is live.
RE2 has no such problem, because it guarantees time linear in the input. That’s the choice OWASP Coraza made. In v3 the @rx operator compiles rules with Go’s regexp (and rsc.io/binaryregexp for patterns that match non-UTF-8 bytes), and coraza-wasilibs can swap in go-re2 for speed. The v2-era PCRE plugin, jptosso/coraza-pcre, wraps the cgo PCRE1 binding gijsbers/go-pcre. Its README says it “is only intended for compatibility”, and its last commit is from June 2022. The Go WAF with the most PCRE-shaped rule set chose a linear-time engine.
What I’d use
Start with the least powerful engine that fits your patterns:
- Go’s
regexpwhen RE2 syntax is enough. Reach for go-re2 if you have large inputs or complex patterns; its README warns it is slower than the standard library on small ones. - regexp2 when you need lookaround or backreferences. Set both limits:
import (
"errors"
"time"
"github.com/dlclark/regexp2/v2"
)
func compileRule(pattern string) *regexp2.Regexp {
re := regexp2.MustCompile(pattern,
regexp2.OptionMaxBacktrackingStackSize(100_000), // the default, made explicit
)
re.MatchTimeout = 50 * time.Millisecond
return re
}
func matches(re *regexp2.Regexp, subject string) (bool, error) {
ok, err := re.MatchString(subject)
if errors.Is(err, regexp2.ErrBacktrackingStackLimit) {
// the pattern hit the stack cap: log it, and decide whether to block
return false, err
}
return ok, err // err is non-nil here only on timeout
}
- PCRE2 through wasm2go only if a pattern you can’t rewrite needs one of the five features above, and you’re willing to own the build for years.
cgo bindings (jemmic/go-pcre2 for PCRE2, GRbit/go-pcre for PCRE1) give you the real library with the JIT, and they cost exactly what made me pick go-sqlite3: a C toolchain in the build and no clean cross-compilation. modernc.org/libpcre2-8 uses ccgo to translate PCRE2’s C straight to Go. That avoids both cgo and Wasm, but the generated code is per-platform and the API is low-level.
What would change my mind
A maintained go-pcre2 on wasm2go, released alongside each PCRE2 version, with honest benchmarks against a native JIT build. The pieces are all there now: wasm2go is proven on larger C codebases than PCRE2, and go-re2 shows how to wrap a regex engine around it. If someone publishes it, option 3 becomes “import it”, and I’d use it for the patterns regexp2 can’t handle.
Update: so I built it
The evening after publishing this, I looked again for a maintained go-pcre2 on wasm2go. There wasn’t one, so I made franchb/go-pcre2. It is PCRE2 10.49:
- compiled with wasi-sdk, JIT off;
- translated to Go with wasm2go;
- wrapped in an API close to
regexp, except that match methods return anerror, because a backtracking match can hit a limit.
The module version follows PCRE2: v0.49.0 is PCRE2 10.49. A daily job opens a bump PR when PCRE2 tags a release.
CI checks two properties rather than leaving them as promises:
- Reproducible output. Every run regenerates the 55,000 lines of generated Go from the pinned PCRE2 commit, and fails if they differ from what is committed.
- No imports. The Wasm module had none, and the build failed if PCRE2 ever needed one. A regex has no way to reach files, the network, the clock or the process. Since callouts (below) it has exactly one import, and the build fails if that ever changes.
For the “honest benchmarks against a native JIT build” part, the repo also has a cgo backend (-tags pcre2_cgo). It links native PCRE2 built from the same commit, with the JIT on or off. CI runs every engine on one runner, so the numbers below come from the same machine.
What it costs
64 KiB of log-like text, medians of 5 runs, a 4-vCPU GitHub runner (AMD EPYC 7763), MB/s:
| pattern | Go regexp | regexp2 | go-pcre2 | PCRE2 interpreter (native) | PCRE2 JIT (native) |
|---|---|---|---|---|---|
\$\d+\.\d\d | 156 | 118 | 190 | 296 | 457 |
\b(\w+) \1\b | n/a | 6.8 | 13.4 | 30.1 | 140 |
(?<=\$)\d+\.\d\d | n/a | 53.7 | 51.3 | 117 | 318 |
[\w.]+@[\w.]+\.com | 20.3 | 83.5 | 17.7 | 44.8 | 273 |
one 10-byte MatchString, ns | 119 | 176 | 323 | 209 | 154 |
- Against regexp2: go-pcre2 is faster on the simple pattern and the backreference, ties on lookbehind, and loses badly on the email pattern. Its per-call overhead is about twice regexp2’s.
- Against PCRE2’s own interpreter compiled natively: the translation costs 1.5× to 2.5× on these four patterns. A plain literal search that never matches is the exception: there the native interpreter is 8× faster than go-pcre2, and the JIT is actually slower than the interpreter.
- The JIT is a separate multiplier, 1.5× to 6.1× on these four patterns, and the largest on the email pattern.
The full tables and method are in docs/BENCHMARKS.md.
What I got wrong first
The first CI run measured 1.7 MB/s on the simple pattern, about 50× behind regexp2. The cause was mine, not wasm2go’s. I had defaulted to PCRE2_MATCH_INVALID_UTF, and in pcre2_match.c that option “forces a check, overriding the setting of PCRE2_NO_UTF_CHECK”. Every call re-validated the subject from the start offset to the end, so FindAll was quadratic.
Three changes followed:
- Validate once. Checking the subject one time per
FindAllbrought the simple pattern to 0.84× of regexp2. - Validate in Go.
utf8.Validreplaced PCRE2’s byte-by-byte check, reaching 1.0×. - wasm2go’s
-unsafeoption. Generating with it gave about 2.5× across the board, reaching 1.6×. The option only changes the generated load and store helpers: each access still runs an explicit bounds check on its last byte, then does an unaligned load.
Two roadmap items, built and merged
The README ended with a two-item roadmap. The next morning I built both and measured them. I first left both PRs unmerged, and after reading the results again I merged them.
A lighter path for very small subjects. I wrote this item because a 10-byte MatchString cost 323 ns, against 176 for regexp2, and I assumed the price was entering the Wasm module. A CPU profile said otherwise. wasm2go’s exports are plain Go method calls, so the boundary costs next to nothing. About 72% of the call was pcre2_match itself: roughly a fifth of the call was its setup and the rest the interpreter. Most of the remainder was my wrapper building a slice of offsets on every match, even for MatchString, which returns only a bool.
#7 fixes the wrapper:
MatchStringno longer allocates;- methods that return only the whole match read only that pair;
FindAlltakes its results from shared chunks.
On the same runner type (EPYC 7763), the small call went from 323 to 275 ns, and the simple pattern from 190 to 214 MB/s. FindAll dropped from 1,013 allocations on the corpus to 19. The cgo backend gained too: its JIT went from 457 to 606 MB/s on the simple pattern, because the allocations had been the same there. It is a modest gain, and it doesn’t change the picture: per call, go-pcre2 is still about 1.5× regexp2. No “lighter path” exists to build. The remaining cost is PCRE2’s own interpreter, run through translated code.
Callouts. (?C1) and (?C"text") call back into the host during a match. They are PCRE2’s escape hatch for checks a regex can’t express, such as “this octet is at most 255”. They are also the one feature that needs a host import. PCRE2 calls the callout through a function pointer, and in Wasm that pointer has to name a function inside the module. So the module needs a small C shim that forwards the callout block to an imported function, and the Go wrapper implements that import.
#8 does exactly that:
- the build check becomes “exactly this one import”;
- the import only calls the Go function you pass to
OnCallout, and is installed only on patterns compiled with it; - the shim asserts the callout block’s field offsets at compile time, so a future PCRE2 that moves a field breaks the build instead of returning wrong values.
It works on both backends, with tests under -race. A panic in the callback is recovered before it can unwind through PCRE2, and then raised again in the caller, so the Regexp stays usable.
Two costs made me hesitate:
- Speed. Small calls got about 5% slower on my machine, even for patterns with no callouts. The hot generated functions were byte-for-byte identical, so I couldn’t attribute it.
- The sandbox claim. “Zero imports” is a fact a single command can check. “One import, and it only calls your code” is a sentence a reviewer has to read and believe.
I merged it anyway. The import grants nothing beyond code the caller already runs, and the build still fails if the import list changes. Callouts are the one PCRE2 feature that was missing.
One finding from that morning holds independently of both PRs. Building outside the container, from the wasi-sdk 25 release tarball, produced the same .wasm hash as CI’s container build. For the callout variant, CI regenerated the code and found nothing to commit. The generated code doesn’t depend on my build environment, at least not between these two.
Fuzzing in CI
PCRE2’s C code is already fuzzed upstream: it ships a libFuzzer harness and runs on OSS-Fuzz. Fuzzing PCRE2’s logic again would mostly repeat that work. What OSS-Fuzz doesn’t see is what I added:
- the translation into 55,000 lines of Go;
- the wrapper’s offsets, UTF-8 shortcut, empty-match rule and instance pool;
- the callout path.
So the fuzzing in #10 aims there, with Go’s native fuzzer. With -fuzz, go test instruments every package the test imports apart from a few runtime and testing packages, so the fuzzer is coverage-guided through the translated PCRE2 itself. I expected the instrumentation to make a 55,000-line file too slow to fuzz. It didn’t: on four cores each target runs 2,600 to 7,000 inputs a second and keeps finding new coverage.
There are four targets, and they check invariants rather than expected outputs:
- Compile and match.
Compilenever panics. Offsets stay in bounds and on character boundaries. The different methods agree with each other. ARegexpbehaves the same after an error as a fresh one. - Callouts. The fuzzer decides what each callout returns: continue,
Fail, an error or a panic. - A differential target. This is the only one with a real oracle. The wasm2go module and native PCRE2 with the JIT off run the same C code from the same commit, so they must return the same result code and offsets. Any difference is a translation or wrapper bug. I had planned a refactor so both backends could be linked into one test binary. It turned out unnecessary: the cgo build of the test drives the generated module directly with a 60-line driver. The heap limit has to stay off, because PCRE2’s heap frames hold pointers, which are 4 bytes in Wasm and 8 natively.
The seeds come from the existing tests and from PCRE2’s own test data: 5,522 patterns and 6,681 subjects. Every CI run fuzzes each target for 30 seconds, and the differential one for 60. A nightly job gives each target 20 minutes, keeping its corpus in the Actions cache between nights. A failing input is committed as a regression test that plain go test runs from then on.
What it found, so far:
- No bugs. About 750,000 executions locally and 207,000 more in CI’s first differential run, with no failure. The wasm2go module and native PCRE2 never disagreed. That is the first evidence beyond PCRE2’s own test suite that the translation is faithful.
- One of my invariants was wrong. PCRE2’s tests use
\C, which matches a single byte even in UTF-8 mode, so a match can legitimately split a character. The boundary check now skips patterns that contain it. - The cgo backend is only as good as the PCRE2 it links. Against the system’s PCRE2 10.42,
\Kreached through a subroutine call inside a lookahead, as in(?(DEFINE)(?<sneaky>b\K))a(?=(?&sneaky))onab, returns a match that ends before it starts. 10.49 rejects these at match time. The wasm2go backend carries its own pinned PCRE2, so it is not affected. With the cgo backend on an old system library,FindStringon such a pattern would panic while slicing the subject.
Getting that last finding right took one false start. For a while my local cgo test binary was silently linked against the system’s 10.42 rather than the 10.49 I had built, and the differential target skipped itself on the version mismatch while reporting “ok”. I only trusted its results once strings on the test binary showed it really contained 10.49.
So what do I use now
The list above changes in one place. If a pattern needs PCRE2 itself (\K, recursion, subroutine calls, branch reset), I’d now import go-pcre2 rather than build anything. On simple patterns it is competitive with regexp2. Everything about hostile input still applies: it is a backtracking engine, so set MatchLimit and handle the error.
Links
- ncruces/wasm2go: the Wasm-to-Go translator, with its users list and security notes
- wasilibs/go-re2: RE2 via wasm2go since v1.12.0
- dlclark/regexp2: pure-Go .NET-flavor regex, and regexp2cg for code-generated matchers
- PCRE2: upstream, with the JIT docs and support lifecycle
- OWASP Coraza and coraza-wasilibs
- bobby-stripe/go-pcre: the 2021 PCRE2-in-Wasm experiment
- franchb/go-pcre2: PCRE2 via wasm2go, built after this post, with benchmarks
- Mapping SQLite’s WAL-Index into WebAssembly Memory: the go-sqlite3 post this one follows