How to Use Lookahead and Lookbehind in JavaScript Regular Expressions
Most regex patterns consume characters as they match. They move through the string position by position, eating up text as they go. But sometimes you need to match something only if it is followed by or preceded by a certain pattern, without including that surrounding context in the match itself. This is exactly what lookahead and lookbehind assertions do.
These zero-width assertions let you peek forward or backward in the string, check a condition, and then decide whether the current match should succeed, all without consuming any characters. They are the secret behind elegant solutions for password validation, number formatting, conditional matching, and complex text parsing.
In this guide, you will learn all four types of lookaround assertions, understand how they work internally, and see real-world patterns that would be difficult or impossible without them.
What Are Lookaround Assertions?
A lookaround is a special regex construct that checks whether a pattern exists at a certain position in the string but does not consume any characters. The regex engine "looks" ahead or behind, verifies the condition, and then continues matching from the same position.
There are four types:
| Type | Syntax | Meaning |
|---|---|---|
| Positive Lookahead | X(?=Y) | Match X only if followed by Y |
| Negative Lookahead | X(?!Y) | Match X only if not followed by Y |
| Positive Lookbehind | (?<=Y)X | Match X only if preceded by Y |
| Negative Lookbehind | (?<!Y)X | Match X only if not preceded by Y |
The key concept: lookarounds are zero-width. They assert a condition at a position but do not include the checked text in the match result.
Positive Lookahead: X(?=Y)
A positive lookahead matches X only when it is immediately followed by Y. The Y part is checked but not included in the match.
Basic Syntax and Behavior
let pattern = /\d+(?= dollars)/g;
let text = "I have 100 dollars and 50 euros and 200 dollars.";
console.log(text.match(pattern));
Output:
[ '100', '200' ]
The pattern \d+(?= dollars) matches one or more digits only if they are followed by " dollars". Notice that "50" is not matched because it is followed by " euros", not " dollars". Also notice that the matched result is just the digits, not the word "dollars." The lookahead checked for it but did not consume it.
How It Works Step by Step
Let's trace how the engine processes /\d+(?= dollars)/ against "100 dollars":
- The engine matches
\d+, consuming"100" - It reaches the lookahead
(?= dollars) - It peeks ahead from the current position and checks: is
" dollars"next? - Yes, it is. The lookahead succeeds
- The match result is
"100"(the lookahead text is not included)
If the lookahead had failed:
- The engine matches
\d+, consuming"50" - It reaches
(?= dollars)and peeks ahead - The next text is
" euros", not" dollars" - The lookahead fails
- The entire match attempt at this position fails
Lookahead Does Not Move the Position
This is the most important concept. After a lookahead check, the engine's position stays where it was before the lookahead. This means other patterns can still match the same text:
let pattern = /\d+(?= dollars) dollars/g;
let text = "100 dollars";
console.log(text.match(pattern));
Output:
[ '100 dollars' ]
The lookahead checked for " dollars", confirmed it exists, and then the literal " dollars" in the pattern matched and consumed those characters.
Multiple Lookaheads at the Same Position
You can chain multiple lookaheads to enforce several conditions simultaneously at the same position:
// Match a position followed by both a digit and then a letter somewhere
let pattern = /(?=.*\d)(?=.*[a-z])/;
console.log(pattern.test("abc123")); // true (has digit AND letter)
console.log(pattern.test("123456")); // false (no letter)
console.log(pattern.test("abcdef")); // false (no digit)
Output:
Output:
true
false
false
Each lookahead starts checking from the same position because neither one consumes any characters. This chaining technique is the foundation of password validation patterns.
Negative Lookahead: X(?!Y)
A negative lookahead matches X only when it is not followed by Y. It is the inverse of the positive lookahead.
Basic Syntax
let pattern = /\d+(?! dollars)/g;
let text = "100 dollars and 50 euros and 200 dollars";
console.log(text.match(pattern));
Output:
[ '10', '50', '20' ]
Wait, why "10" and "20" instead of nothing? This is a subtle but important behavior. Let's break it down:
- At
"100": the engine tries\d+and greedily matches"100". Then it checks(?! dollars)and finds" dollars"follows. The lookahead fails. But the engine backtracks: it tries matching"10"instead. After"10", the next character is"0", not" dollars". The lookahead succeeds. So"10"matches. - The same logic produces
"20"from"200".
To correctly match whole numbers not followed by "dollars," add a word boundary or more context:
let pattern = /\b\d+\b(?! dollars)/g;
let text = "100 dollars and 50 euros and 200 dollars";
console.log(text.match(pattern));
Output:
[ '50' ]
Now \b\d+\b matches complete numbers, and (?! dollars) ensures they are not followed by " dollars".
Negative lookaheads with greedy quantifiers can produce unexpected partial matches due to backtracking. Always consider whether you need word boundaries or other anchors to match complete tokens.
Matching Words Not Followed by Something
// Match "java" only when NOT followed by "script"
let pattern = /java(?!script)/gi;
let text = "I know Java, JavaScript, and JavaBeans.";
console.log(text.match(pattern));
Output:
[ 'Java', 'Java' ]
The first "Java" (standalone) matches because it is followed by ",", not "script". The "Java" in "JavaScript" is skipped. The "Java" in "JavaBeans" matches because it is followed by "Beans".
Excluding Specific Patterns
// Match all <div> tags except self-closing ones
let pattern = /<div(?!\s*\/>)[^>]*>/g;
let html = '<div class="a"> <div/> <div class="b"> <div />';
console.log(html.match(pattern));
Output:
[ '<div class="a">', '<div class="b">' ]
The negative lookahead (?!\s*\/>) excludes <div/> and <div /> patterns.
Positive Lookbehind: (?<=Y)X
A positive lookbehind matches X only when it is immediately preceded by Y. The lookbehind checks what comes before the current position.
Basic Syntax
let pattern = /(?<=\$)\d+(\.\d{2})?/g;
let text = "Prices: $100, €50, $29.99, £75, $0.50";
console.log(text.match(pattern));
Output:
[ '100', '29.99', '0.50' ]
The pattern (?<=\$)\d+ matches digits only when preceded by a $ sign. The $ is checked but not included in the match. Only the number is captured.
Extracting Values After Labels
let pattern = /(?<=temperature:\s*)\d+/gi;
let data = "Temperature: 72, Humidity: 45, Temperature: 68";
console.log(data.match(pattern));
Output:
[ '72', '68' ]
The lookbehind (?<=temperature:\s*) ensures we only capture numbers that follow "temperature:" with optional whitespace.
Lookbehind with Fixed and Variable Length
In most regex engines, lookbehinds must have a fixed length. JavaScript (V8 engine) is more flexible and allows variable-length lookbehinds:
// Variable-length lookbehind - works in modern JavaScript
let pattern = /(?<=\$\s*)\d+/g;
let text = "$100 and $ 200 and $ 300";
console.log(text.match(pattern));
Output:
[ '100', '200', '300' ]
The \s* inside the lookbehind matches zero or more spaces, making the lookbehind variable-length. This works in modern JavaScript engines but may not be supported in older environments.
Variable-length lookbehinds work in Chrome, Edge, Node.js (V8), and Firefox. Safari added support in version 16.4+. If you need broad compatibility, stick to fixed-length lookbehinds or test thoroughly.
Negative Lookbehind: (?<!Y)X
A negative lookbehind matches X only when it is not preceded by Y.
Basic Syntax
let pattern = /(?<!\$)\b\d+\b/g;
let text = "$100 and 200 and $50 and 75";
console.log(text.match(pattern));
Output:
[ '200', '75' ]
The pattern matches numbers that are not preceded by a dollar sign. $100 and $50 are excluded, while 200 and 75 match.
Matching Words Not Preceded by Something
// Match "Smith" only when NOT preceded by "Mr. "
let pattern = /(?<!Mr\.\s)Smith/g;
let text = "Mr. Smith met Jane Smith and Dr. Smith at the park.";
console.log(text.match(pattern));
Output:
[ 'Smith', 'Smith' ]
The "Smith" after "Mr." is excluded. The ones after "Jane" and "Dr." are matched.
Preventing Matches Inside Larger Words
// Match "port" but not when part of "import", "export", "transport"
let pattern = /(?<!im|ex|trans)port/g;
let text = "import export transport port airport";
console.log(text.match(pattern));
Output:
[ 'port', 'port' ]
Here, "port" inside "import", "export", and "transport" is excluded. The standalone "port" and "port" inside "airport" match (since "air" is not "im", "ex", or "trans").
Combining Lookaheads and Lookbehinds
You can use lookaheads and lookbehinds together in the same pattern for precise matching.
Extracting Content Between Delimiters
// Match text between quotes, without including the quotes
let pattern = /(?<=")[^"]+(?=")/g;
let text = 'He said "hello" and she said "goodbye" to everyone.';
console.log(text.match(pattern));
Output:
['hello', ' and she said ', 'goodbye']
The lookbehind (?<=") checks for an opening quote, [^"]+ matches the content, and the lookahead (?=") checks for the closing quote. Neither quote is included in the match.
Matching Between HTML Tags
// Extract content between <b> tags without including the tags
let pattern = /(?<=<b>).*?(?=<\/b>)/g;
let html = "<p>This is <b>bold</b> and <b>important</b> text.</p>";
console.log(html.match(pattern));
Output:
[ 'bold', 'important' ]
Matching a Word Surrounded by Specific Context
// Match numbers that are between parentheses
let pattern = /(?<=\()\d+(?=\))/g;
let text = "Call (555) 123-4567 or (800) 999-0000. Reference: 42";
console.log(text.match(pattern));
Output:
[ '555', '800' ]
The number 42 is not matched because it is not inside parentheses.
Use Case: Password Validation
Password validation is the classic use case for multiple lookaheads. You can enforce several requirements simultaneously by chaining positive and negative lookaheads at the start of the string.
Basic Password Requirements
// Password must:
// - Be at least 8 characters long
// - Contain at least one uppercase letter
// - Contain at least one lowercase letter
// - Contain at least one digit
let strongPassword = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/;
console.log(strongPassword.test("MyPass1234")); // true
console.log(strongPassword.test("mypass1234")); // false (no uppercase)
console.log(strongPassword.test("MYPASS1234")); // false (no lowercase)
console.log(strongPassword.test("MyPassword")); // false (no digit)
console.log(strongPassword.test("MyP1")); // false (too short)
Output:
true
false
false
false
false
Let's break down how this works:
| Part | What It Does |
|---|---|
^ | Anchored to start of string |
(?=.*[A-Z]) | Look ahead: at least one uppercase letter somewhere |
(?=.*[a-z]) | Look ahead: at least one lowercase letter somewhere |
(?=.*\d) | Look ahead: at least one digit somewhere |
.{8,} | Actually match 8 or more characters |
$ | Anchored to end of string |
All three lookaheads start checking from the same position (the start of the string). Each one scans through the entire string with .* looking for its required character. If all three succeed, the engine then proceeds to match .{8,}$.
Adding Special Character Requirement
let strongPassword = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
console.log(strongPassword.test("MyPass12!")); // true
console.log(strongPassword.test("MyPass1234")); // false (no special char)
Output:
true
false
Adding a Negative Lookahead for Exclusions
// Password must NOT contain spaces
let password = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?!.*\s).{8,}$/;
console.log(password.test("MyPass12")); // true
console.log(password.test("My Pass 12")); // false (contains spaces)
Output:
true
false
A Complete Password Validator Function
function validatePassword(password) {
let checks = {
length: /.{8,}/.test(password),
uppercase: /(?=.*[A-Z])/.test(password),
lowercase: /(?=.*[a-z])/.test(password),
digit: /(?=.*\d)/.test(password),
special: /(?=.*[!@#$%^&*()_+\-=\[\]{}|;:,.<>?])/.test(password),
noSpaces: /^(?!.*\s)/.test(password)
};
let isValid = Object.values(checks).every(Boolean);
return { isValid, checks };
}
console.log(validatePassword("MyP@ss99"));
console.log(validatePassword("weak"));
Output:
{
isValid: true,
checks: { length: true, uppercase: true, lowercase: true, digit: true, special: true, noSpaces: true }
}
{
isValid: false,
checks: { length: false, uppercase: false, lowercase: true, digit: false, special: false, noSpaces: true }
}
Use Case: Number Formatting with Thousand Separators
This is one of the most elegant uses of lookaround assertions. Adding commas to large numbers:
// Add commas as thousand separators: 1234567 → 1,234,567
let formatted = "1234567890".replace(
/\B(?=(\d{3})+(?!\d))/g,
","
);
console.log(formatted);
Output:
1,234,567,890
This pattern is dense but powerful. Let's break it down:
| Part | Meaning |
|---|---|
\B | A non-word boundary (not at the start of the number) |
(?= | Positive lookahead: look ahead for... |
(\d{3})+ | One or more groups of exactly three digits |
(?!\d) | Negative lookahead: NOT followed by another digit |
) | End of outer lookahead |
The pattern finds positions within the number where the remaining digits form groups of three. At each such position, it inserts a comma.
You can wrap this in a reusable function:
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
console.log(formatNumber(1000)); // "1,000"
console.log(formatNumber(1000000)); // "1,000,000"
console.log(formatNumber(42)); // "42"
console.log(formatNumber(9999999999)); // "9,999,999,999"
Output:
1,000
1,000,000
42
9,999,999,999
For production code, Number.prototype.toLocaleString() or the Intl.NumberFormat API handles number formatting with locale awareness. The regex approach is excellent for learning lookarounds and works well for simple formatting needs.
Use Case: Complex Text Parsing
Extracting Currency Amounts Without the Symbol
// Get amounts that follow any currency symbol
let pattern = /(?<=[$€£¥])\d+(?:\.\d{2})?/g;
let text = "Prices: $29.99, €45.00, £12.50, ¥1000, and 500 units.";
console.log(text.match(pattern));
Output:
[ '29.99', '45.00', '12.50', '1000' ]
The 500 is excluded because it is not preceded by a currency symbol.
Splitting on Delimiters Without Removing Them
Normally, split() removes the delimiter. Lookarounds let you split while keeping the delimiters:
// Split before uppercase letters (camelCase → words)
let camel = "backgroundColor";
let words = camel.split(/(?=[A-Z])/);
console.log(words);
Output:
[ 'background', 'Color' ]
The lookahead (?=[A-Z]) finds positions before uppercase letters without consuming them, so the uppercase letter stays with the second part.
// Convert camelCase to kebab-case
function camelToKebab(str) {
return str
.replace(/(?<=[a-z])(?=[A-Z])/g, "-")
.toLowerCase();
}
console.log(camelToKebab("backgroundColor")); // "background-color"
console.log(camelToKebab("fontSize")); // "font-size"
console.log(camelToKebab("borderTopLeftRadius")); // "border-top-left-radius"
Output:
background-color
font-size
border-top-left-radius
The pattern (?<=[a-z])(?=[A-Z]) matches the zero-width position between a lowercase and an uppercase letter. It inserts a hyphen at that position.
Parsing Key-Value Pairs with Context
// Extract values only when the key is "color" or "size"
let pattern = /(?<=(?:color|size):\s*)\S+/g;
let css = "color: red; font-size: 14px; size: large; weight: bold; color: blue;";
console.log(css.match(pattern));
Output:
[ 'red;', 'large;', 'blue;' ]
Matching Whole Words Not Inside Quotes
// Match "TODO" only when not inside quotes
// This is simplified (real string parsing needs a proper parser)
let pattern = /(?<!")TODO(?!")/g;
let code = `
// TODO: fix this
let msg = "TODO: do not match this";
// TODO: also fix this
`;
console.log(code.match(pattern));
Output:
[ 'TODO', 'TODO' ]
Using lookarounds to detect whether text is "inside quotes" works only for simple cases. Proper string-aware parsing requires a state machine or tokenizer, not regex alone.
Lookaround with Capturing Groups
Lookaround assertions can contain capturing groups. The groups capture text even though the lookaround itself does not consume characters:
// Capture the currency symbol in a lookbehind
let pattern = /(?<=(?<currency>[$€£]))\d+(?:\.\d{2})?/g;
let text = "$100 and €250.50 and £75";
let match;
while ((match = pattern.exec(text)) !== null) {
console.log(`Amount: ${match[0]}, Currency: ${match.groups.currency}`);
}
Output:
Amount: 100, Currency: $
Amount: 250.50, Currency: €
Amount: 75, Currency: £
The lookbehind checks for a currency symbol and captures it in a named group, even though the symbol is not part of the main match.
Lookaround vs. Consuming Alternatives
Understanding when to use a lookaround versus a regular consuming pattern:
// WITHOUT lookahead ("USD" is consumed, included in the match)
let consuming = /\d+ USD/g;
let text = "100 USD and 200 EUR";
console.log(text.match(consuming));
// Output: [ '100 USD' ]
// WITH lookahead ("USD" is checked but NOT in the match)
let lookahead = /\d+(?= USD)/g;
console.log(text.match(lookahead));
// Output: [ '100' ]
Use lookarounds when:
- You need the context for matching but do not want it in the result
- You need to check multiple conditions at the same position (chained lookaheads)
- You want to insert text at specific positions using
replace() - You need to split at positions without losing delimiter characters
Use regular consuming patterns when:
- You want the context included in the match
- The pattern is simpler without lookarounds
- Performance is critical and the pattern is in a hot loop
Common Mistakes
Mistake 1: Confusing Lookahead Direction
// WRONG: lookbehind syntax where lookahead is needed
// "Match digits followed by px"
let wrong = /(?<=px)\d+/g; // This checks what comes BEFORE, not after
// CORRECT
let right = /\d+(?=px)/g;
let text = "width: 100px; height: 200px;";
console.log(text.match(right));
Output:
[ '100', '200' ]
Remember:
- Lookahead
(?=...)checks what comes after (to the right) - Lookbehind
(?<=...)checks what comes before (to the left)
Mistake 2: Expecting Lookarounds to Consume Characters
// Expecting the match to include the lookahead content
let pattern = /\d+(?=\s*dollars)/g;
let text = "I have 100 dollars";
let match = text.match(pattern);
console.log(match[0]); // "100" (not "100 dollars"!)
Output:
100
If you want "100 dollars" in the result, do not use a lookahead. Just write the full pattern:
let pattern = /\d+\s*dollars/g;
console.log("I have 100 dollars".match(pattern));
Output:
[ '100 dollars' ]
Mistake 3: Greedy Quantifiers Causing Partial Matches with Negative Lookahead
// Intention: match numbers NOT followed by "px"
// WRONG: greedy \d+ backtracks to find a match
let bad = /\d+(?!px)/g;
let text = "100px 200em 300px";
console.log(text.match(bad));
Output:
[ '10', '200', '30' ]
The engine matches "100" but the negative lookahead sees "px" and fails. It backtracks to "10" where the next character is "0", not "px". So "10" matches. This is almost never what you want.
The fix depends on your intent:
// Match complete numbers not followed by "px"
let good = /\b\d+\b(?!px)/g;
let text = "100px 200em 300px";
console.log(text.match(good));
Output:
[ '200' ]
Word boundaries \b force the engine to match the complete number, preventing partial backtracked matches.
Mistake 4: Using Lookbehind with Unsupported Variable Length
While modern JavaScript engines support variable-length lookbehinds, some older environments do not:
// May not work in older browsers
let pattern = /(?<=\w+\s+)\d+/g;
// Fixed-length alternative that works everywhere
let safe = /(?<=\w\s)\d+/g;
If you need to support older environments, restructure the pattern to avoid variable-length lookbehinds or use a capturing group approach instead:
// Instead of lookbehind, capture what you need
let pattern = /\w+\s+(\d+)/g;
let text = "price 100 quantity 50";
let match;
while ((match = pattern.exec(text)) !== null) {
console.log(match[1]); // The number part only
}
Output:
100
50
Performance Considerations
Lookarounds generally add a small performance cost because the engine must check additional conditions at each position. A few guidelines:
- Simple lookarounds like
(?=\d)or(?<=\$)are very fast - Lookarounds with
.*like(?=.*[A-Z])scan the rest of the string, which is slower on long inputs - Multiple chained lookaheads (as in password validation) each scan independently, multiplying the work
- Nested quantifiers inside lookarounds can cause excessive backtracking
For performance-critical code, consider whether a non-regex approach might be simpler:
// Regex password validation (scans string multiple times)
let regex = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
// Imperative approach (single pass through the string)
function validatePassword(pw) {
if (pw.length < 8) return false;
let hasUpper = false, hasLower = false, hasDigit = false, hasSpecial = false;
for (let ch of pw) {
if (ch >= 'A' && ch <= 'Z') hasUpper = true;
else if (ch >= 'a' && ch <= 'z') hasLower = true;
else if (ch >= '0' && ch <= '9') hasDigit = true;
else hasSpecial = true;
}
return hasUpper && hasLower && hasDigit && hasSpecial;
}
Both approaches are valid. For typical password lengths (under 100 characters), the regex version is perfectly fine. For scanning large text buffers, the imperative approach may be faster.
Quick Reference Table
| Assertion | Syntax | Reads As | Example | Matches |
|---|---|---|---|---|
| Positive Lookahead | X(?=Y) | X followed by Y | /\d+(?=px)/ | "12" in "12px" |
| Negative Lookahead | X(?!Y) | X NOT followed by Y | /\d+(?!px)/ | "12" in "12em" |
| Positive Lookbehind | (?<=Y)X | X preceded by Y | /(?<=\$)\d+/ | "50" in "$50" |
| Negative Lookbehind | (?<!Y)X | X NOT preceded by Y | /(?<!\$)\d+/ | "50" in "€50" |
Key properties shared by all four:
- Zero-width: they do not consume characters
- Assert a condition: they pass or fail without affecting the match length
- Can contain groups: capturing inside lookarounds works
- Can be chained: multiple lookarounds at the same position enforce multiple conditions
Summary
Key takeaways:
- Positive lookahead
(?=Y)matches a position followed byYwithout consuming it - Negative lookahead
(?!Y)matches a position not followed byY - Positive lookbehind
(?<=Y)matches a position preceded byYwithout consuming it - Negative lookbehind
(?<!Y)matches a position not preceded byY - Lookarounds are zero-width assertions: they check conditions but do not include the checked text in the match
- Multiple lookaheads can be chained at the same position to enforce several conditions, which is the basis of password validation patterns
- Lookarounds enable powerful operations like inserting text at specific positions, splitting without removing delimiters, and extracting content between delimiters
- Watch out for backtracking with negative lookaheads: greedy quantifiers can produce surprising partial matches. Use word boundaries or anchors to match complete tokens
- Modern JavaScript engines support variable-length lookbehinds, but older environments may not
Lookaround assertions transform regex from a simple pattern-matching tool into a context-aware engine. Once you internalize the four types, patterns that seemed impossible become straightforward.