Regex Lookahead and Lookbehind, Explained With Practical Examples
Lookaround is the regex feature people reach for when a normal pattern won't do, then abandon because the match keeps coming back empty or grabbing the wrong characters. The fix is almost always understanding one word: zero-width.
A lookahead or lookbehind checks whether something exists next to your position without consuming any characters. It asserts, it doesn't match. Once that clicks, password rules and split-on-delimiter patterns stop being guesswork.
This guide walks through all four forms with examples you can paste into the regex lookaround tester and watch run. It also covers the part most tutorials skip: which engines support lookbehind, because some of the patterns below will throw an error in Go or Rust.
What "zero-width" actually means
Every normal token in a regex consumes input. \d matches a digit and advances the cursor past it. [a-z]+ eats one or more letters and the cursor lands after them.
A lookaround does neither. It looks at the text around the current cursor position, returns true or false, and leaves the cursor exactly where it was. Nothing gets added to the match. That is what "zero-width" means: the assertion has no width in the result.
You have already used a zero-width assertion if you have ever written ^, $, or \b. \b doesn't match a character, it matches the boundary between a word character and a non-word character. Lookahead and lookbehind are the same idea, except you get to define the condition yourself instead of relying on a built-in one.
The four forms:
| Syntax | Name | Asserts |
|---|---|---|
(?=...) | Positive lookahead | What follows matches ... |
(?!...) | Negative lookahead | What follows does NOT match ... |
(?<=...) | Positive lookbehind | What precedes matches ... |
(?<!...) | Negative lookbehind | What precedes does NOT match ... |
The lookahead forms have no extra character after the ? for positive, a ! for negative. The lookbehind forms add a < to flip the direction. That < is the whole mnemonic: the arrow points back.
Positive lookahead: matching only when something follows
Say you want to match the word price but only when it is immediately followed by a colon. You don't want the colon in your match, you just want to confirm it's there.
price(?=:)Against the text price: 40, priceless, price: this matches the two price tokens that sit right before a colon and ignores priceless. The colon itself is never part of the match. In a highlighter you'll see only the five letters lit up.
The classic real use is inserting thousands separators. To add commas to 1234567 you match each position that has a multiple-of-three digits remaining to its right:
\d(?=(\d{3})+$)This finds every digit followed by groups of exactly three digits running to the end of the string. Replace each match with itself plus a comma and you get 1,234,567. The lookahead does the counting without consuming the digits it counted, so they're still available for the next match. The common variant \B(?=(\d{3})+(?!\d)) matches the zero-width positions instead, so you replace with just a comma; both produce the same output.
Negative lookahead: matching only when something is absent
Flip (?=...) to (?!...) and you assert the opposite: the following text must NOT match.
A common need is matching foo that isn't followed by bar:
foo(?!bar)Against foobar foobaz foo this skips foobar and matches the foo in foobaz and the standalone foo at the end. The assertion looks at what comes after the cursor, which sits right after foo, and rejects only when bar is there.
Negative lookahead is also how you exclude keywords. To match any word that is not null, undefined, or NaN, anchor a negative lookahead at the start:
\b(?!null\b|undefined\b|NaN\b)\w+The \b inside each alternative matters. Without it, nullable would be rejected because it starts with null. With the word boundary, only the exact words are excluded and nullable still matches.
The password-rule pattern: stacking lookaheads
This is the example everyone eventually needs and the one that makes lookahead worth learning. A password validator has to check several independent conditions: contains a digit, contains an uppercase letter, contains a symbol, is long enough. Each condition is a positive lookahead, and you stack them at the start of the pattern.
^(?=.*\d)(?=.*[A-Z])(?=.*[!@#$%^&*])(?=.{8,}$).+Read it left to right after the ^ anchor:
(?=.*\d)— somewhere ahead there is a digit(?=.*[A-Z])— somewhere ahead there is an uppercase letter(?=.*[!@#$%^&*])— somewhere ahead there is one of these symbols(?=.{8,}$)— at least eight characters until end of string.+— finally, actually consume the password
The detail that confuses people: each lookahead starts checking from the same position, right after ^. Because they're zero-width, none of them move the cursor. They all independently scan forward from the start, return true or false, and the cursor stays parked at position zero. Only the trailing .+ consumes anything.
If any single condition fails, the whole match fails, which is exactly the all-or-nothing behavior a validator wants. To require no whitespace, add (?!.*\s) as another stacked assertion. Paste the pattern into the lookaround tester and toggle characters in a test string to watch which lookahead trips the failure. Note that . does not match newlines by default, so a multiline string can sneak past (?=.{8,}$); the (?!.*\s) assertion closes that gap.
One caveat worth stating plainly: matching a password against a regex tells you the password meets a complexity policy. It is not security. Hashing happens elsewhere, and a regex should never gate anything more than client-side or form-level input checks.
Lookbehind: asserting what came before
Lookbehind checks the text immediately to the left of the cursor. Positive lookbehind (?<=...) requires a match there; negative lookbehind (?<!...) requires the opposite.
To match a number only when it is a dollar amount, assert a $ sits behind it without capturing the symbol:
(?<=\$)\d+(\.\d{2})?Against $40 costs 40 this matches only the 40 that follows the dollar sign. The $ is never in the result, so you can extract the bare numeric value directly.
Negative lookbehind helps when you want to match something except in a specific preceding context. To find every com that is not part of .com:
(?<!\.)comThis matches com in command but skips it in example.com because a dot precedes it there.
Here is the constraint that trips people up. Several engines require the lookbehind to match a constant number of characters, or at most a bounded range. (?<=abc) is fine everywhere. (?<=\d{2,4}) has a known maximum width and is accepted by Java, .NET, and recent PCRE2. (?<=a+) is unbounded and is rejected by every engine except .NET, which alone allows unlimited-length lookbehind. Python's built-in re is the strictest: it requires a single fixed width and rejects even (?<=\d{2,4}). Test against your target runtime before you ship.
Splitting on delimiters without consuming them
A practical place lookaround pays off: splitting a string at a boundary while keeping the delimiter, or splitting at a position where no actual character exists.
Take camelCase. You want to split camelCaseValue into words, but there's no separator character to split on. The boundary lives between a lowercase letter and an uppercase letter. A split pattern that matches a zero-width position works:
(?<=[a-z])(?=[A-Z])This matches the empty position that has a lowercase letter behind it and an uppercase letter ahead. Splitting camelCaseValue on it yields camel, Case, Value. No letters are consumed because both assertions are zero-width, so nothing is lost from the output. (Acronyms like parseHTTPResponse need extra rules, since there's no lowercase-to-uppercase boundary inside HTTP.)
Another case: split a CSV line on commas, but not commas inside quotes. The full quote-aware pattern gets gnarly, but a lookahead handles the simple version where you split on a comma only when an even number of quotes follows:
,(?=(?:[^"]*"[^"]*")*[^"]*$)The lookahead counts quote pairs ahead to confirm the comma is outside a quoted field. This is the kind of pattern worth building incrementally in a tester rather than typing in one shot, because a single misplaced bracket silently changes the meaning. It also breaks on escaped quotes ("") inside fields, so reach for a real CSV parser in production. The lookaround tester shows matches live as you edit, which is the fastest way to confirm each piece before adding the next.
Engine support: where lookaround breaks
Not every regex engine supports lookaround, and the ones that do disagree on lookbehind.
| Engine | Lookahead | Lookbehind |
|---|---|---|
| JavaScript (V8, modern) | Yes | Yes, variable-length (ES2018+) |
Python re | Yes | Yes, fixed-width only |
Python regex (PyPI) | Yes | Yes, variable-length |
| PCRE2 (PHP, many tools) | Yes | Yes, bounded-length (10.44+) |
Java java.util.regex | Yes | Yes, bounded-length |
| .NET | Yes | Yes, unlimited variable-length |
Go regexp (RE2) | No | No |
Rust regex | No | No |
The two that catch people are Go and Rust. Both use a finite-automaton engine (RE2 and a RE2-inspired design) that guarantees linear-time matching and refuses any feature that would require backtracking. Lookaround and backreferences are exactly those features. A pattern with (?=...) that works in your editor's find dialog will fail to compile in Go with an error about an unsupported construct. If you need those features in Go, the third-party regexp2 package supports them at the cost of the linear-time guarantee.
If you're targeting Go or Rust and need a lookaround-style check, you usually restructure: split into multiple separate regex passes, or do the surrounding-context test in code rather than in the pattern. The password validator above, for instance, becomes four independent regexp.MatchString calls in Go, one per condition.
Variable-length lookbehind is the second trap. A pattern like (?<=\w+\s) uses an unbounded \w+, so it works in JavaScript and .NET but is rejected by Java, PCRE2, and Python's built-in re, all of which need a known maximum width. PCRE2 added bounded variable-length lookbehind in version 10.44 (June 2024) with a default cap of 255 characters per branch; older PCRE builds are fixed-width only. When a lookbehind needs to match a range of widths, either give it a bounded quantifier, switch to a lookahead from an earlier anchor, or move to an engine that allows it. Confirm against your actual target runtime, not just whatever the tester uses under the hood, which is the JavaScript engine.
When not to use lookaround
Lookaround is powerful enough that people overuse it. A few rules of thumb keep patterns readable and portable.
If a capture group does the job, use a capture group. To extract the number from $40, \$(\d+) with a captured group is clearer and works in every engine, including Go and Rust. Reach for lookbehind only when you genuinely cannot consume the surrounding character, usually because you're inside a split or a global replace where the delimiter must survive.
If you're stacking more than three or four lookaheads, ask whether the logic belongs in code. A password policy expressed as separate boolean checks is easier to read, easier to give per-rule error messages for, and doesn't break when someone ports it to a different language.
And if performance matters on large inputs, remember that lookaround forces a backtracking engine. That's fine for short strings and form fields. For scanning megabytes of log data, a linear-time engine without lookaround will usually win, even if it means a clumsier pattern or a two-pass approach.
Lookaround is a precision tool for asserting context, not a general-purpose hammer. Once you can name which of the four forms you need and confirm your target engine supports it, the patterns above cover the large majority of real cases.
Skip the manual work. The companion tool runs this in your browser, with nothing uploaded.
Regex lookaround testerFrequently asked questions
What is the difference between lookahead and lookbehind in regex?
Lookahead checks the text immediately after the current position, lookbehind checks the text immediately before it. Both are zero-width: they assert a condition without consuming characters, so the matched text stays the same. Use lookahead with (?=...) or (?!...) and lookbehind with (?<=...) or (?
Why does my lookbehind throw an error in PHP or Python?
Python's built-in re module only allows fixed-width lookbehind, meaning the pattern inside must match a constant number of characters, so even (?<=\d{2,4}) is rejected. PCRE2 (used by PHP) allows bounded variable-length lookbehind as of version 10.44 but still rejects unbounded quantifiers like (?<=\d+). Either make the width fixed or bounded, rewrite it as a lookahead from an earlier anchor, or use Python's third-party regex module, which allows variable-length lookbehind.
Do lookahead and lookbehind work in Go and Rust?
No. Go's regexp package (RE2) and Rust's regex crate use finite-automaton engines that guarantee linear-time matching and do not support lookaround or backreferences. If you need lookaround-style logic, split it into multiple separate regex passes, do the context check in code, or use a backtracking engine like Go's third-party regexp2 package.
How do I write a regex for a password with a digit, uppercase letter, and symbol?
Stack positive lookaheads at the start: ^(?=.*\d)(?=.*[A-Z])(?=.*[!@#$%^&*])(?=.{8,}$).+ — each lookahead independently scans from position zero and the trailing .+ consumes the actual password. A regex only checks complexity, it is not a substitute for proper password hashing.
Why does my lookahead match nothing or zero characters?
Lookaround is zero-width by design. If your entire pattern is just (?=foo), it matches an empty position before foo and the result is an empty string. You need a normal token outside the lookaround to actually consume text, for example foo(?=bar) matches foo, not the assertion.
Can I split a string on a delimiter without removing the delimiter?
Yes. Split on a zero-width lookaround position instead of on the character itself. For camelCase, (?<=[a-z])(?=[A-Z]) matches the empty boundary between a lowercase and uppercase letter, so splitting keeps both letters intact.