Skip to content
Personal blog at franchb.com
Go back

PCRE in Go in 2026: wasm2go Is Ready, PCRE2 Isn't

Updated:

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:

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 featureSyntaxregexp2
Reset match start\Kno
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:

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:

  1. Go’s regexp when 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.
  2. 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
}
  1. 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:

  1. compiled with wasi-sdk, JIT off;
  2. translated to Go with wasm2go;
  3. wrapped in an API close to regexp, except that match methods return an error, 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:

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:

patternGo regexpregexp2go-pcre2PCRE2 interpreter (native)PCRE2 JIT (native)
\$\d+\.\d\d156118190296457
\b(\w+) \1\bn/a6.813.430.1140
(?<=\$)\d+\.\d\dn/a53.751.3117318
[\w.]+@[\w.]+\.com20.383.517.744.8273
one 10-byte MatchString, ns119176323209154

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:

  1. Validate once. Checking the subject one time per FindAll brought the simple pattern to 0.84× of regexp2.
  2. Validate in Go. utf8.Valid replaced PCRE2’s byte-by-byte check, reaching 1.0×.
  3. wasm2go’s -unsafe option. 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:

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:

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:

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:

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:

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:

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.


Share this post:

Previous Post
Mapping SQLite's WAL-Index into WebAssembly Memory