Skip to content

Lesson 5 of 9 · 20m

Lookarounds: Matching on Context Without Consuming It

Lookahead and lookbehind as zero-width assertions, engine support differences, and when a lookaround is cleverness you should replace with code.

Lookarounds let a pattern ask "what's next to me?" without including the answer in the match. They're the most powerful construct in Perl-style regex and the most abused. Used well, they make a replace operation surgical. Used badly, they turn a validation rule into an unreadable wall that nobody dares touch.

Four assertions, zero width

  • X(?=Y) — positive lookahead: X, only if followed by Y
  • X(?!Y) — negative lookahead: X, only if not followed by Y
  • (?<=Y)X — positive lookbehind: X, only if preceded by Y
  • (?<!Y)X — negative lookbehind: X, only if not preceded by Y

"Zero-width" is the whole idea: the engine checks the condition, then returns to the exact position it started from. Nothing is consumed and nothing ends up in match[0].

The cursor doesn't move

const withCommas = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
console.log(withCommas(1234567));   // 1,234,567
console.log(withCommas(999));       // 999

Read it as: "a non-boundary position, followed by one or more groups of exactly three digits, followed by a non-digit." The match is an empty string at each insertion point; the replacement inserts a comma there. A good party trick. In production, use Intl.NumberFormat, which also knows that India groups digits as 12,34,567.

Lookbehind for surgical replacement

const log = "POST /login user=alice password=hunter2&remember=1";
console.log(log.replace(/(?<=password=)[^&\s]+/g, "[REDACTED]"));
// POST /login user=alice password=[REDACTED]&remember=1

Without lookbehind you'd capture password= and re-insert it with $1. The lookbehind version says what you mean: replace the value, keep the key.

Support varies more than anything else in regex

  • JavaScript (ES2018+): arbitrary-length lookbehind, (?<=Bearer\s+)\S+ works.
  • Python re: lookbehind must be fixed width. The same pattern raises re.error: look-behind requires fixed-width pattern.
  • PCRE and Java: bounded-length lookbehind only.
  • RE2, Go, grep -E, ripgrep's default engine, Prometheus: no lookarounds at all. Reach for grep -P or rg -P.

The password-policy trap

^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^\w\s]).{12,64}$

It works, and it's bad engineering. The user gets one "invalid password" message instead of "add a digit." Each (?=.*X) rescans the input from the start. And NIST SP 800-63B discourages composition rules entirely in favor of length and breach-list checks. Four separate test() calls give you better UX, better errors, and code your successor can read.

Field notes

  • Lookarounds are atomic in Perl-style engines: once satisfied, the engine won't backtrack into them to try alternatives. Captures inside a lookahead are frozen at first success.
  • "Lines not containing X" is grep -v X, not ^(?!.*X).*$. Pipeline beats pattern.
  • Lookarounds can match at overlapping positions, because nothing is consumed. Useful for counting overlapping occurrences, surprising everywhere else.
  • If a pattern needs three lookaheads to express a business rule, it wants to be three lines of code.