Lookahead and lookbehind¶
One line: Lookaround asserts what is beside the current position and consumes nothing, so several assertions can hold at the same place — and the engines disagree, sharply, about how wide a lookbehind may be.
Level: 201 · you write patterns regularly and want the rules, not the recipes
Keywords on this page: (?=…) · (?<=…) · \K — Topics: Validating input · Extracting fields
If you arrived looking for "forward references", this is probably the page you wanted: the other one is about reusing captured text, while lookaround is about position.
Zero-width is the whole idea¶
\d+(?= EUR) matches digits followed by EUR, and the EUR is not part of the match. The engine checks the assertion, then rewinds to where it was. Because nothing is consumed, two assertions can be true at the same position — which is how one pattern states several independent rules.
Verified output of lookaround_py.py — regenerated by tools/run_examples.py, never hand-typed.
['250']
['25', '90']
Sunshine1 True
sunshine1 False
Sun1 False
['250']
variable width: look-behind requires fixed-width pattern
uneven branches: look-behind requires fixed-width pattern
Read that output line by line, because two of them are traps.
['25', '90'] is the output of \d+(?! EUR) against 250 EUR and 90 USD, and almost nobody predicts it. The intent was "a number not followed by EUR", but \d+ is free to stop early: after matching 25, the next character is 0, which is not EUR, so the assertion holds and 25 is a perfectly good match. The fix is to pin the end of the run — \d+\b(?! EUR) — and the lesson is that a negative lookahead constrains only the position it sits at, not the match you imagined.
The password block is lookahead used the way it is used in real code: ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$. Three assertions at position zero, each scanning the whole string, then .{8,} does the actual consuming. Without zero-width assertions you would need one pattern per ordering of the character classes.
The two errors are Python's lookbehind rule: it must be a known width. Python refuses (?<=EUR\s{1,4}) and refuses (?<=EUR|USD ) too — branches of different lengths count as a variable width in re, even though each branch alone is fixed.
The width rule, engine by engine¶
This is where portable patterns go to die. Same construct, five answers:
| Engine | Fixed width | Branches of different fixed widths | Genuinely variable width |
|---|---|---|---|
Python re |
yes | no | no |
| Perl | yes | yes | yes (5.30+, bounded) |
PCRE2 / ABAP PCRE |
yes | yes | yes, if bounded (10.43+) |
| Java | yes | yes | yes, if bounded |
| Ruby | yes | yes | no |
| JavaScript | yes | yes | yes — any width, even * |
Rust fancy-regex |
yes | no | no |
Rust regex, Go regexp |
no lookaround at all | — | — |
Those cells come from the probe, which hands each engine the pattern and reports whether it compiled.
JavaScript is the permissive one¶
Verified output of lookaround_js.js — regenerated by tools/run_examples.py, never hand-typed.
(?<=EUR\s*) — an unbounded lookbehind — compiles and works. V8 matches a lookbehind right-to-left, which is why it needs no width at all.
And then the last interesting line: negative: [ '250', '0' ]. The pattern (?<!USD )\d+ against EUR 250 USD 90 was meant to find amounts that are not dollars. It found 250, correctly skipped the 9 of 90 — and then matched the 0, whose preceding characters are 9, not USD. Negative lookbehind has the same failure mode as negative lookahead: it constrains one position, so anchor the other end ((?<!USD )\b\d+).
Rust¶
Verified output of lookaround_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
regex: refused
regex parse error:
foo(?=bar)
^^^
error: look-around, including look-ahead and look-behind, is not supported
fancy-regex: fixed-width lookbehind -> Some("250")
fancy-regex: variable-width lookbehind -> Error compiling regex: Look-behind assertion without constant size
regex refuses lookaround for the same reason it refuses backreferences: a finite automaton with a linear-time guarantee cannot express it. fancy-regex adds it, and lands on Python's side of the width rule — Look-behind assertion without constant size is a compile error, not a slow match.
ABAP¶
z_lookaround.prog.abap in full — pasted here by tools/run_examples.py from the file CI runs.
REPORT z_lookaround.
" ABAP's PCRE addition is the PCRE2 library, so lookaround is the Perl one:
" lookahead of any width, lookbehind of a known width, and alternatives of
" different fixed lengths allowed. Source only -- nothing here can run ABAP.
DATA(text) = `250 EUR and 90 USD`.
" Lookahead: the amount that is followed by EUR, without capturing EUR.
FIND ALL OCCURRENCES OF PCRE `\d+(?= EUR)` IN text RESULTS DATA(amounts).
" Lookbehind: the amount that follows a currency code. Each branch is a fixed
" width, which PCRE2 allows even though the two branches differ.
DATA(prices) = `EUR 250 USD 90`.
FIND ALL OCCURRENCES OF PCRE `(?<=EUR |USD )\d+` IN prices RESULTS DATA(found).
" \K is PCRE's other way of saying the same thing: everything before it is
" matched and then dropped from the reported match.
FIND FIRST OCCURRENCE OF PCRE `EUR \K\d+` IN prices RESULTS DATA(kept).
ABAP's PCRE addition is PCRE2, so the permissive-but-bounded rules apply, including \K:
Real run — PCRE2 10.48 (pcre2grep), macOS 26.6.2 x86-64, 2026-09-21. Subject file: EUR 250 on line 4, USD 90 on line 5.
$ pcre2grep -on "(?<=EUR |USD )\d+" subject.txt
4:250
5:90
$ pcre2grep -on "(?<=EUR\s{1,4})\d+" subject.txt
4:250
Both compile: branches of different lengths, and a bounded variable width. The same two patterns are errors in Python.
\K deserves a mention because it is PCRE-only and reads better than a long lookbehind: EUR \K\d+ matches EUR 250 and then discards everything before the \K, reporting only 250. It has no width restriction at all. It is available in ABAP's PCRE and in Perl; Python, JavaScript, Java, Go and Rust have nothing like it.
Rules worth keeping¶
- Anchor the far end of a negative assertion.
(?!…)and(?<!…)say something about one position; add\bor$to say it about the whole run. - Stack positive lookaheads for independent rules. It is the clearest way to write "all of these must be true", and it reads better than one enormous alternation.
- Keep lookbehinds fixed-width if the pattern must travel. Fixed width is the only form every engine here accepts.
- Remember lookaround forces a backtracking engine. In Rust it means
fancy-regexinstead ofregex; in Go it means restructuring. See what a backreference costs.
Try it¶
- Predict, then run,
\d+(?! EUR)against250 EUR. Then fix it so it finds nothing. - Write "a line containing
ERRORbut notERROR: ignored" as one pattern, using a negative lookahead at the start of the line. - Take a fixed-width lookbehind that works in Python and widen one branch. Note that the failure is at compile time — then decide whether you would rather have had JavaScript accept it silently.
See also¶
- Forward references — the other "looks in the wrong direction" feature, and the names people mix up.
- What a backreference costs — why an engine might refuse lookaround on purpose.
- Who supports what — the measured table.