Regex lookahead and lookbehind
A lookaround matches a position, not characters. The four forms, why they consume nothing, and the one your browser only got in March 2023.
Nearly every explanation of lookahead starts with what it checks. The useful place to start is what it does not do: it does not consume. A lookaround examines the text around the current position, reports whether it likes what it sees, and then leaves the position exactly where it found it. Nothing it inspected becomes part of the match.
That one property explains every otherwise-odd thing lookaround does — why two of them can sit on top of each other, why a whole password rule matches zero characters, and why removing a lookahead from a pattern changes the length of the result. If you want the token table rather than the mechanism, it is in the cheat sheet; this page is about why the tokens behave the way they do.
What regex lookahead gives back
Two patterns, the same subject, and the difference is the whole idea:
/foo(?=bar)/.exec("foobar") // match "foo", index 0, ends at 3
/foobar/.exec("foobar") // match "foobar", index 0, ends at 6
Both require the text foobar to be present. Only one of them reports it. Watch what that
does to a substitution:
"foobar".replace(/foo(?=bar)/, "X") // "Xbar"
"foobar".replace(/foobar/, "X") // "X"
The lookahead version replaced three characters, because three characters were matched. The
bar was a condition on the match, never part of it. This is the reason lookahead is the
right tool for “change A, but only when B follows” — the alternative is capturing bar and
putting it back with $1, which works and reads worse.
The four forms
| Form | Reads as | Example | Against |
|---|---|---|---|
(?=…) |
What follows must match | \w(?=\d) |
a1 finds a |
(?!…) |
What follows must not match | [a-z](?!\d) |
a1 ab finds a and b |
(?<=…) |
What precedes must match | (?<=USD )\d+ |
USD 42 EUR 9 finds 42 |
(?<!…) |
What precedes must not match | (?<!\$)\b\d+\.\d{2}\b |
$9.99 and 4.50 finds 4.50 |
The last row is the everyday case for lookbehind: pick out a number that is not a price, without the currency symbol ending up in the match and without capturing it into a group you then have to ignore.
Read the negative forms carefully. (?!…) succeeds when the thing inside fails, which
means a negative lookahead at the end of the subject succeeds trivially — there is nothing
there to match, so there is nothing to fail. That is usually what you want, and occasionally
it is a bug you will stare at for a while.
A whole password rule that matches nothing
The classic multi-condition pattern is where zero-width stops being a technicality:
const rule = /^(?=.*[a-z])(?=.*\d).{8,}$/
rule.test("abcdefgh") // false — no digit
rule.test("abcdefg1") // true
rule.test("ABCDEFG1") // false — no lowercase
Three requirements, checked independently, in one pass. Written without lookahead this needs
either a list of alternations covering every order the characters could appear in, or three
separate test calls. The lookaheads work because each one starts at position zero, scans
forward with .* to find its evidence, and then hands the position back untouched for the
next one to start from the same place.
Which is also why the whole (?=…)(?=…) prefix contributes nothing to the reported match. The
.{8,}$ at the end is doing all the consuming; strip it and the pattern matches the empty
string at index 0 while still enforcing both rules. That is a legitimate technique — an
assertion-only pattern used purely as a yes-or-no test.
The same shape inverted is the most useful line-filter in the language:
/^(?!.*ERROR).*$/.test("GET /ok 200") // true
/^(?!.*ERROR).*$/.test("GET /x 500 ERROR") // false
“Every line that does not contain this word” has no direct spelling in regex. A negative lookahead anchored at the start of the line is how you say it.
Regex lookbehind is the newest piece, and it is not everywhere
Lookahead arrived with ES3 in 1999 and has been safe to use for decades. Lookbehind did not, and its arrival is recent enough that it is worth checking before you ship one.
| Engine | First version with lookbehind |
|---|---|
| Chrome | 62 |
| Firefox | 78 |
| Safari, and iOS Safari | 16.4 |
| Edge | 79 |
| Samsung Internet | 20 |
Safari is the one that matters, because it sets the date: MDN records lookbehind as available across browsers since March 2023, which is when Safari 16.4 shipped. That is not ancient history. It is inside the support window of plenty of corporate fleets and of any device still running iOS 16.3, and a lookbehind in a pattern is not a graceful degradation — the regular expression literal fails to parse, which takes the whole script file with it rather than the one feature that used it.
There is a second, less obvious catch. Lookbehind in JavaScript may be variable length, and in most other engines it may not:
/(?<=ab+)c/.test("abbbc") // true in JavaScript
PCRE refuses that pattern outright with lookbehind assertion is not fixed length, and
Python’s re with look-behind requires fixed-width pattern. Perl draws the line somewhere
else again and complains about lookbehind longer than 255. PCRE does allow alternatives of
differing fixed lengths — (?<=cat|horse)s compiles there — but not a quantifier that could
run on. So the portability problem points the other way from usual: a lookbehind written for
JavaScript is the one that fails to compile elsewhere.
Lookbehind runs backwards, and the greed goes with it
This is the behavior that catches people who already know lookaround, and it is not a quirk so much as an inevitable consequence. A lookbehind knows where it must end — the current position — but not where it should start, so the engine matches it from right to left.
/^(\d+)(\d+)$/.exec("1053") // ["1053", "105", "3"]
/(?<=(\d+)(\d+))$/.exec("1053") // ["", "1", "053"]
Same two greedy groups, same subject, mirrored result. Going forward, the first group grabs everything it can and leaves the second the minimum. Going backwards, the group nearest the end is the one that gets fed first. Nothing here is undefined behavior; it falls straight out of the direction of travel. But a capture group inside a lookbehind will not divide the text the way reading left to right suggests, and code that assumed otherwise will be wrong in a way that looks like an off-by-one.
When lookaround is not the answer
Two honest limits. A lookaround does not put anything in the match, so it is never the way to
collect text — that is what capture groups are for, and reaching for an assertion to avoid
one usually makes the pattern harder to read for no gain. And every lookahead of the
(?=.*x) shape re-scans the remaining text from the current position, so a handful of them
inside a repeated group multiplies work the engine has already done. That is cheap on a
password field and worth thinking about on a megabyte of log.
POSIX tools have no lookaround at all. grep -E 'a(?=b)' fails with repetition-operator
operand invalid — the ? has nothing to repeat, because (?= is not a construct there.
Watching an assertion match nothing
Lookaround is hard to reason about and trivial to observe, which makes it the family that
benefits most from being run rather than read. The regex tester reports the
start and end index of every match rather than just its text, so a zero-width result shows up
as exactly what it is — a start and an end at the same number — instead of looking like a
failure. Type foo(?=bar) against foobar and the index panel makes the entire idea concrete
in one glance.
For comparing two variants of a document rather than testing one pattern against it, the text comparison tool is the better instrument, and it will also tell you when the difference you are chasing is a line ending rather than a character you can see.