Zero-Width Lookaround Assertions
Lookahead and lookbehind are zero-width assertions. They test a condition at a position in the string without consuming any characters — so the match result excludes the lookaround itself. This makes them perfect for extracting text in context, for constraining matches by what surrounds them, and for find-and-replace operations that must leave surrounding characters untouched.
The Four Variants
(?=X) Positive lookahead — assert X CAN match right of here
(?!X) Negative lookahead — assert X CANNOT match right of here
(?<=X) Positive lookbehind — assert X ended just before here
(?<!X) Negative lookbehind — assert X did NOT end just before hereLookahead in Practice
// Positive — match digits followed by " dollars"
/\d+(?= dollars)/g
// Input: "Earned 100 dollars, spent 50 euros, saved 25 dollars"
// Matches: ["100", "25"]
// Note: " dollars" is NOT part of the match
// Negative — digits NOT followed by " dollars"
/\d+(?! dollars)/g
// Matches: ["50"] (plus substring matches depending on anchoring)
// With a word boundary for cleaner results
/\b\d+\b(?! dollars)/g
// Matches: ["50"]Lookbehind in Practice
// Positive — digits preceded by a dollar sign
/(?<=\$)\d+/g
// Input: "Items $10, $25 shoes, line 50"
// Matches: ["10", "25"]
// Note: the "$" is NOT part of the match
// Negative — digits NOT preceded by a dollar sign
/(?<!\$)\b\d+\b/g
// Matches: ["50"]Combining Lookaround
// Extract the dollar part of a currency amount
/(?<=\$)\d+(?=\.\d{2})/g
// Input: "Price $42.99 and $7.50 shipping"
// Matches: ["42", "7"]
// Neither "$" nor ".99" are consumedLanguage-Specific Usage
JavaScript
// Password that must contain a digit AND a letter — all lookaheads
const pwd = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/;
pwd.test("abc12345"); // true — has letter + digit, 8+ chars
pwd.test("abcdefgh"); // false — no digit
pwd.test("12345678"); // false — no letter
// Price extraction with lookbehind + lookahead
"Items $10, $25 shoes".match(/(?<=\$)\d+(?=\b)/g);
// => ["10", "25"]
// Find all occurrences of "cat" NOT preceded by "wild"
"cat and wildcat and tomcat".match(/(?<!wild)cat/g);
// => ["cat", "cat"] (both 'cat' standalone and inside 'tomcat')
// For whole-word only: /(?<!\w)(?<!wild)cat\b/gPython
import re
# Positive lookahead — version number followed by " stable"
re.findall(r'\d+\.\d+(?= stable)', 'v2.1 stable, v3.0 beta, v2.5 stable')
# => ['2.1', '2.5']
# Positive lookbehind — Python stdlib re needs FIXED-length lookbehind
re.findall(r'(?<=\$)\d+', 'price $42, cost $99')
# => ['42', '99']
# Variable-length lookbehind — need the 'regex' module
import regex # pip install regex
regex.findall(r'(?<=price\s+\$)\d+', 'price $42 and price $99')
# => ['42', '99']PHP
$text = "Earned 100 dollars, saved 50 euros";
preg_match_all('/\d+(?= dollars)/', $text, $m);
// $m[0] => ["100"]
// PCRE supports variable-length lookbehind
preg_match_all('/(?<=price\s+\$)\d+/', 'price $42 and price $99', $m);
// $m[0] => ["42", "99"]Common Pitfalls
Order inside the lookaround matters
(?=\d+)abc is almost always a mistake — the engine looks ahead for digits but then tries to match abc at the same position. Put literal matches outside the assertion: abc(?=\d+).
Negative lookahead is not "match everything else"
/(?!dollars)\w+/doesn't mean "every word except dollars". It means "at each position, if the next few chars are not dollars, consume word chars". On the input "dollars", the engine advances past the first d and matches ollars because the lookahead at that position succeeds. Use anchors: /^(?!dollars$)\w+$/.
Fixed vs variable length lookbehind
Python's standard re module requires fixed-length lookbehind: (?<=abc) works, (?<=a+) fails. JavaScript, PHP (PCRE2) and .NET all support variable-length lookbehind. If you need Python with variable-length lookbehind, use the third-party regex module.
Safari / older browser support
Lookbehind in JavaScript was added in ES2018. Safari didn't support it until 16.4 (March 2023). On older iOS/macOS, /(?<=\$)/ throws a SyntaxError. If you need to ship to those users, do the matching in two steps: find a prefix, then slice.
Lookaround cannot capture
Groups inside a lookaround can hold capture values, but the lookaround itself contributes zero characters to the overall match. If you need the character you're "looking at" in the result, use a regular capturing group instead of a lookaround.
Lookaround Cheatsheet
| Assertion | Example | What it does |
|---|---|---|
| (?=X) | /\d+(?=px)/ | digits before "px" |
| (?!X) | /\d+(?!px)/ | digits NOT before "px" |
| (?<=X) | /(?<=\$)\d+/ | digits after "$" |
| (?<!X) | /(?<!\$)\d+/ | digits NOT after "$" |
Testing Your Lookaround Regex
Use the live Regex Tester above with:
Test string:
"Price $42.99, saved 20 bucks, size 300px, loss -$7.50"
Patterns to try:
/(?<=\$)\d+/g — matches "42", "7" (digits after $)
/\d+(?=px)/g — matches "300" (digits before px)
/(?<![\d.])\d+(?=px)/g — matches "300" cleanly (skip inside numbers)
/(?<=-\$)\d+\.\d+/g — matches "7.50" (negative amounts only)Performance Notes
Simple lookahead and fixed-length lookbehind are extremely fast — zero-width means the engine just reads a few characters without allocating a match. The expensive variants are variable-length lookbehind (reconsiders many starting positions) and nested lookarounds with quantifiers. For hot paths, prefer capture-and-slice over complex lookbehind:const [, prefix, num] = str.match(/(\$)(\d+)/) is often faster than str.match(/(?<=\$)\d+/) in V8.