Regex Lookaround in 5 Engines: Python, PHP, .NET, JS
(?=), (?!), (?<=) and (?<!) run with the same patterns in Python, Perl, PHP, JavaScript (Dart) and .NET. Where lookbehind length limits differ, the greedy trap, and a broken 2021 example.
Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran every example in five engines: Python 3.9
re, Perl 5.34, PHP 8.5 (PCRE2 10.47), Dart 3.11 (whose RegExp follows JavaScript syntax) and .NET 10. The original's last example did not work as described in any of them, and lookbehind support differed between engines. Every result below was measured.
What lookaround is
Lookaround checks whether a pattern exists just ahead of (or just behind) the current position without including it in the match. Think of it as a zero-width condition.
| Syntax | Name | Meaning |
|---|---|---|
(?=…) |
positive lookahead | … must follow |
(?!…) |
negative lookahead | … must not follow |
(?<=…) |
positive lookbehind | … must precede |
(?<!…) |
negative lookbehind | … must not precede |
Lookahead (?=)
.+(?=:) matches the text before a colon and leaves the colon out. Writing the colon literally (.+:) would include it, giving http:.
| Text | .+(?=:) (same in all 5 engines) |
|---|---|
http://www.abc.com |
http |
http://www.abc.com:8080 |
http://www.abc.com ← the trap |
The second row is what the 2021 article missed. .+ is greedy: it takes as much as it can, up to the last colon. If you want text only up to the first colon:
| Pattern | Result on http://www.abc.com:8080 |
|---|---|
.+?(?=:) (lazy) |
http, ://www.abc.com (two matches) |
[^:]+(?=:) (no colons) |
http, //www.abc.com (two matches) |
^[^:]+(?=:) (anchored) |
http (one match) |
All five engines agreed on all three. If you want "just the scheme", the anchored pattern says so most clearly.
Another example: .+(?=원) pulls 1000 out of 1000원 (the Korean currency unit won).
Lookbehind (?<=)
Lookbehind looks backwards. (?<=\$)[0-9.]+ extracts the number right after a $. $ is a metacharacter, so it is escaped as \$.
| Text | Result (same in all 5 engines) |
|---|---|
1: $600.4 2: $10.25 |
600.4, 10.25 |
(?<=,)[a-z\s!]+ on Hello, world! returns world! with a leading space, not world!. The character class contains \s, so the space after the comma is captured too. Put the space inside the lookbehind instead: (?<=,\s)[a-z!]+.
Negative lookbehind (?<!), and a correction
The 2021 article applied \b(?<!\$)\d+ to $105$677$788 and said it finds "numbers not preceded by $". In practice all five engines return no match. Every number in that text directly follows a $, so there is nothing to find. The spaces were probably lost when the post was migrated. With spaces:
| Pattern | Result on $105 677 $788 |
|---|---|
\b(?<!\$)\d+ |
677 |
(?<![\d$])\d+ |
677 |
The \b is there to stop a match from starting in the middle of a number (the 05 in $105). The second pattern gets the same result by requiring "no digit and no $ before", without a word boundary.
Where engines differ: lookbehind length
This is the most important part. How long a lookbehind may be varies by engine.
- Pattern ①:
(?<=\$|USD )\d+, where the alternatives have different lengths (1 and 4 characters) - Pattern ②:
(?<=\$\s*)\d+, where the length is unbounded
| Pattern | Text | Python 3.9 re |
Perl 5.34 | PHP 8.5 (PCRE2 10.47) | JavaScript (Dart 3.11) | .NET 10 |
|---|---|---|---|---|---|---|
| ① | $5 USD 7 |
error | 5, 7 (experimental warning) |
5, 7 |
5, 7 |
5, 7 |
| ② | $ 42 |
error | error | error | 42 |
42 |
The actual messages:
Python : look-behind requires fixed-width pattern
PHP : Compilation failed: length of lookbehind assertion is not limited at offset 0
Perl : Lookbehind longer than 255 not implemented
Perl : Variable length lookbehind is experimental in regex (warning; it still works)
What to do about it:
- For portable patterns, keep lookbehind contents fixed-length:
(?<=\$),(?<=USD ). - For alternatives of different lengths, split the lookbehind:
(?:(?<=\$)|(?<=USD ))\d+. All five engines, Python included, returned5,7. - If you truly need unbounded context, use a capture group instead.
\$\s*(\d+)matched$ 42in all five engines, with group 1 =42. - In Python, the third-party
regexmodule supports variable-length lookbehind; the standardredoes not.
A practical use: password rules
Several lookaheads at the start of the pattern express "all of these conditions at once":
^(?=.*\d)(?=.*[A-Z]).{8,}$
| Input | Result (same in all 5 engines) |
|---|---|
Passw0rdX |
match (digit, uppercase, 8+ chars) |
password1 |
no match (no uppercase) |
Each (?=…) checks a condition without moving the position, so you can chain as many as you need. When the rules grow, though, separate checks in code give better error messages and are easier to maintain than one regex.
How we tested
The same patterns and texts went into one TSV file, and each engine got a runner of about ten lines that prints every match:
| Engine | Call |
|---|---|
| Python | re.findall |
| Perl | while (/re/g) |
| PHP | preg_match_all with the /u flag |
| Dart | RegExp(..., unicode: true).allMatches |
| .NET | Regex.Matches, run as a .NET 10 single-file app with dotnet run t.cs |
There is no single regex standard. Checking in the engine your code actually uses is the only reliable habit.
FAQ
Can I use * or + inside a lookbehind?
It depends on the engine. An unbounded lookbehind such as (?<=\$\s*) worked only in .NET and JavaScript (tested via Dart). Python's re, PHP (PCRE2 10.47) and Perl 5.34 rejected it at compile time.
Why does .+(?=:) return more than the URL scheme?
Because .+ is greedy: it runs to the last colon. On http://www.abc.com:8080 it matches http://www.abc.com, not http. Use ^[^:]+(?=:) to get only the scheme.
Can lookahead validate a password policy?
Yes. ^(?=.*\d)(?=.*[A-Z]).{8,}$ matches only strings of 8+ characters that contain at least one digit and one uppercase letter. All five engines agreed.