Lookarounds in Go: Why RE2 Says No, and What to Do Instead
Go's RE2 engine rejects lookaheads and lookbehinds outright — why, and three workaround strategies that don't need them.
If you've landed here, you probably just saw this:
error parsing regexp: invalid or unsupported Perl syntax: `(?=`That's Go telling you its regex engine doesn't do lookarounds — not lookahead ((?=...), (?!...)), not lookbehind ((?<=...), (?<!...)). This isn't a missing feature that's coming in a future release. It's a deliberate design decision, and once you understand why, the workarounds make more sense.
Why RE2 refuses
Go's regexp package is built on RE2, which guarantees linear-time matching: match time grows proportionally with input length, always, for every pattern. No pathological cases, no catastrophic backtracking, no regex denial-of-service. That guarantee is why you can safely run user-supplied patterns against user-supplied input in Go.
The guarantee comes from executing patterns as finite automata rather than by backtracking. Lookarounds are zero-width assertions that require the engine to speculatively match ahead (or behind) without consuming input — which maps naturally onto a backtracking engine and not onto an automaton. Supporting them would mean giving up the linear-time property. RE2 chose the guarantee. (So did Rust's regex crate, for the same reason.)
So: PCRE, JavaScript, Python, .NET all backtrack and all support lookarounds. Go and Rust don't backtrack, and don't. It's a tradeoff, not an oversight.
The three workaround strategies
Every lookaround use case falls into one of three buckets. Pick by what the lookaround was doing.
Strategy 1 — Multiple regexes, AND-ed in code
Replaces: stacked lookaheads used as AND-conditions.
The classic case is password validation (strong password pattern):
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d\s]).{8,}$Each (?=...) is really an independent requirement. In Go, just test them independently:
var (
hasLower = regexp.MustCompile(`[a-z]`)
hasUpper = regexp.MustCompile(`[A-Z]`)
hasDigit = regexp.MustCompile(`\d`)
hasSpecial = regexp.MustCompile(`[^a-zA-Z\d\s]`)
)
func strongPassword(s string) bool {
return len(s) >= 8 &&
hasLower.MatchString(s) &&
hasUpper.MatchString(s) &&
hasDigit.MatchString(s) &&
hasSpecial.MatchString(s)
}This is more code than the one-liner — and better code. Each rule is readable, independently testable, and produces a specific failure you can report to the user ("needs an uppercase letter") instead of a boolean shrug. Even in JS and Python, where the lookahead version works, many teams write it this way on purpose.
Same treatment applies to the simpler variants: password with no special chars, positive lookahead demo.
Strategy 2 — Match wider, then trim (capture groups)
Replaces: lookbehind/lookahead used to anchor on context without consuming it.
Lookarounds often exist only to say "match X, but only when it's next to Y, and don't include Y in the result." In Go: match Y too, capture X, keep the capture.
Say you want numbers preceded by USD (in PCRE: (?<=USD )\d+):
re := regexp.MustCompile(`USD (\d+)`)
m := re.FindStringSubmatch("price: USD 250")
if m != nil {
amount := m[1] // "250" — the capture, without "USD "
}The negative variants need one extra step — capture the context and reject in code. Matching decimals not preceded by $ (negative lookbehind pattern, PCRE: (?<!\$)\b\d+\.\d{2}\b):
re := regexp.MustCompile(`(^|[^$])\b(\d+\.\d{2})\b`)
for _, m := range re.FindAllStringSubmatch(input, -1) {
price := m[2] // the char before it (m[1]) is already guaranteed non-$
}Careful with one subtlety: the consumed context character means adjacent matches can overlap differently than the lookbehind version. If matches can be back-to-back, iterate with FindStringSubmatchIndex and manage positions explicitly.
Strategy 3 — Match everything, filter in code
Replaces: negative lookahead used as a blocklist.
To match words that aren't log-level keywords (negative lookahead pattern, PCRE: \b(?!error|warn|debug)[a-z]+\b), match all words and filter:
re := regexp.MustCompile(`\b[a-z]+\b`)
blocked := map[string]bool{"error": true, "warn": true, "debug": true}
var words []string
for _, w := range re.FindAllString(input, -1) {
if !blocked[w] {
words = append(words, w)
}
}Again the Go version has an advantage the regex version doesn't: the blocklist is a data structure. It can come from config, grow to hundreds of entries without turning the pattern into soup, and be checked case-insensitively with a strings.ToLower instead of regex gymnastics.
Splitting on sentence boundaries (sentence boundary pattern, PCRE: [.!?]\s+(?=[A-Z])) is the same shape: in Go, split on [.!?]\s+ and re-attach fragments whose next chunk doesn't start with a capital, or walk matches with FindAllStringIndex and check unicode.IsUpper on the following rune.
Decision table
| Your lookaround was... | Do this in Go |
|---|---|
Stacked (?=.*X)(?=.*Y) AND-rules | Strategy 1: one regex per rule, && in code |
(?<=ctx)X / X(?=ctx) — positional anchor | Strategy 2: consume ctx, capture X |
(?!word) / (?<!ctx) — negative filter | Strategy 3 (words) or Strategy 2 with a code check (context) |
| Genuinely inseparable from backtracking | Don't use regexp — see below |
When no workaround fits
A small class of patterns is genuinely awkward without lookarounds — heavily nested assertions, or patterns you receive from users written in PCRE dialect. Options, in descending order of preference:
- Restructure the problem — usually possible, per the strategies above, and usually yields clearer code.
- Parse, don't regex — if you're reaching for nested lookarounds, you're often parsing structure (URLs, quoted strings, code). Go's standard library (
net/url,encoding/csv,go/scanner) beats regex for all of these. - A backtracking library —
github.com/dlclark/regexp2implements .NET-style regex with full lookaround support. The price: you give up RE2's linear-time guarantee, so never feed it untrusted patterns, and set itsMatchTimeout.
Test it live
Every pattern linked above runs in RegexPro's tester across JS, Python, and Go's actual RE2 engine side by side — paste your pattern and see exactly where it breaks and what the error says: regexpro.dev.
Related guides: Backreferences in Go · Named backreferences in Go