Regex Lookahead and Lookbehind: A Practical Guide

Lookahead and lookbehind are zero-width assertions that test what comes after or before the current position without consuming any characters. There are four: positive lookahead (?=...), negative lookahead (?!...), positive lookbehind (?<=...), and negative lookbehind (?<!...). They match a position, not text.

That single idea trips up most developers, so this guide builds the right mental model first, then walks through the patterns you actually reach for: stacking lookaheads for password rules, matching with context you do not want to capture, and the JavaScript-specific lookbehind caveats that bite in production. Paste every example into a regex tester as you read.

The mental model: the engine does not move

A normal regex token consumes characters. When \d matches a digit, the engine advances past that digit, and the next token starts after it. An assertion is different. It checks a condition at the current position and then the engine stays exactly where it was. Nothing is added to the match, and the cursor does not move forward (lookahead) or backward (lookbehind).

This is why assertions are called zero-width. The classic test is matching a thousands-separator position in a number. The pattern (?<=\d)(?=(\d{3})+$) matches the empty position between digits where exactly groups of three digits remain. You can insert a comma at every match without ever consuming a digit, turning 1234567 into 1,234,567 with a single replace.

The four assertions at a glance

SyntaxNameAsserts
(?=abc)Positive lookaheadWhat follows is abc
(?!abc)Negative lookaheadWhat follows is not abc
(?<=abc)Positive lookbehindWhat precedes is abc
(?<!abc)Negative lookbehindWhat precedes is not abc

Stacking lookaheads for password validation

Because a lookahead does not consume characters, every lookahead you write at the same position evaluates independently against the whole remaining string. That makes them ideal for AND-style rules where each requirement is separate. Password validation is the canonical use case: you want at least one uppercase letter, one lowercase letter, one digit, and one symbol, plus a minimum length.

^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).{8,}$

Read it as a list of conditions anchored at the start. (?=.*[A-Z]) says "somewhere ahead there is an uppercase letter," then the engine returns to position zero. (?=.*[a-z]) does the same for lowercase, and so on. Only after all four assertions pass does .{8,}$ actually consume the string and enforce length. The order of the lookaheads does not matter because none of them move the cursor.

Negative lookahead lets you add denial rules to the same stack. Prepend (?!.*\s) to forbid whitespace anywhere, or (?!.*(.)\1\1) to reject three identical characters in a row. Each new rule is one more independent assertion. This composability is the real argument for lookaround: you express each policy as its own clause instead of one tangled alternation.

A simpler example to build intuition

Match a string that has at least five word characters and contains two consecutive digits, without caring about order: (?=\w{5,})(?=\D*\d{2}). The first lookahead checks length, the second skips non-digits then demands two digits. Both run from the same start position. If you find yourself reaching for case-insensitive flags or reformatting input first, a quick pass through a case converter can normalize text before the pattern runs.

Matching with context you do not capture

Lookbehind shines when you want to match something only because of what precedes it, but you do not want that preceding text in the result. Extracting a currency amount is the textbook case. (?<=\$)\d+(?:\.\d{2})? matches 19.99 inside $19.99 but leaves the dollar sign out of the match entirely. A capturing group would also work, but lookbehind keeps the match itself clean, which matters when you use the global match list directly.

Negative lookbehind handles the inverse. To match the word cat only when it is not preceded by bob, use (?<!bob)cat. Combine the two directions to fence a token on both sides. The pattern (?<=<)\w+(?=>) grabs a tag name out of <div> without the angle brackets. None of the surrounding characters are consumed, so adjacent matches never overlap and steal each other's delimiters.

The JavaScript fixed-width lookbehind caveat

Lookbehind support is uneven across engines, and JavaScript has a specific history worth knowing. ECMAScript 2018 specified full variable-length lookbehind, and V8 (Chrome, Node, Edge) shipped it immediately. The laggard was Safari: JavaScriptCore only gained full lookbehind in Safari 16.4, released March 2023. Before that, lookbehind threw a SyntaxError, and crucially, it is one of the few ES2018 features that cannot be polyfilled.

So on evergreen 2026 targets, variable-length lookbehind like (?<=foo.*)bar works fine. But if you still ship to iOS 15 or earlier Safari, a lookbehind can crash the page for users who physically cannot update their OS. Feature-detect at runtime before relying on it:

let hasLookbehind = false;
try {
  new RegExp("(?<=x)y");
  hasLookbehind = true;
} catch (e) {
  hasLookbehind = false;
}

Other engines draw the line elsewhere. Python's standard re module allows fixed-width lookbehind only, so (?<!foo.*)bar raises "look-behind requires fixed-width pattern"; Matthew Barnett's third-party regex module lifts that limit. Java permits bounded repetition (no open-ended * or +), with fully variable-length lookbehind only arriving in later releases. .NET allows full, infinite lookbehind including backreferences. Go and Rust's RE2 omit lookaround entirely to guarantee linear-time matching; with ripgrep you switch to the PCRE2 engine via rg -P to get it back.

Working around engines without infinite lookbehind

When you are stuck on a fixed-width-only engine (or old Safari) and need variable-length context, rewrite the lookbehind as a capture group and reinsert it. Suppose you want to insert <br> before each newline that is not already preceded by a closing angle bracket. The lookbehind version is /(?<!>)\n/g. The portable equivalent captures the preceding character (or start of string) and puts it back:

// Lookbehind version (modern engines)
text.replace(/(?<!>)\n/g, "<br>");

// Portable version (works everywhere)
text.replace(/([^>]|^)\n/g, "$1<br>");

The ([^>]|^) group consumes the character before the newline (or matches the empty start position), and $1 writes it back unchanged so only the newline is replaced. This pattern, capture the context then re-emit it, is the general escape hatch for any engine that cannot look backward. It is slightly noisier to read, but it runs on every regex flavor and never throws. When in doubt, validate both forms side by side in the regex tester against the same sample text before you ship.

Frequently Asked Questions

Lookahead asserts what comes after the current position, while lookbehind asserts what comes before it. Both are zero-width, meaning they test a condition without consuming any characters or moving the match cursor.

Because a lookahead does not consume characters, every lookahead written at the same position evaluates independently against the rest of the string. This lets you express several AND conditions, like the separate character-class rules in a password pattern, at one anchor point.

Yes, V8 (Chrome, Node, Edge) has supported full variable-length lookbehind since ES2018, and Safari added it in version 16.4 from March 2023. Older Safari and iOS 15 do not support it, and lookbehind cannot be polyfilled, so feature-detect before relying on it.

Capture the preceding context in a group and reinsert it in the replacement. For example, rewrite the lookbehind /(?)\n/g as /([^>]|^)\n/g with a $1 replacement, which keeps the preceding character and works in every regex engine.