← Back to Blog
·9 min read

Lookaheads and Lookbehinds: The Regex Feature That Finally Makes Sense

Lookahead and lookbehind assertions are the most powerful regex feature that most developers either avoid or misuse. Once you understand zero-width assertions, a whole category of problems that previously required multi-step string processing collapses into a single pattern.

Zero-width: what it actually means

Regular expressions consume characters as they match. If \d+ matches 42in a string, those two characters are "consumed" — the regex engine moves past them. Zero-width assertions are different: they check a condition at a position without consuming characters. They assert something about the surrounding context but don't include that context in the match.

This is the key insight. A lookahead says "the match should only succeed if this pattern follows, but don't include that pattern in what was matched." This lets you express conditions like "match a number only if it's followed by a dollar sign" while capturing only the number.

// Without lookahead: captures the "$" too
const priceWithDollar = /$d+.d{2}/;
"Price: $19.99".match(priceWithDollar); // ["$19.99"]

// With lookbehind: captures only the number
const priceOnly = /(?<=$)d+.d{2}/;
"Price: $19.99".match(priceOnly); // ["19.99"]

Positive lookahead: (?=...)

Positive lookahead (?=...) asserts that what follows the current position matches the pattern inside the lookahead. The lookahead itself is not included in the match.

// Password validation: must contain a digit
// Match any string that contains a digit somewhere
const hasDigit = /(?=.*d)/;
hasDigit.test("password");    // false
hasDigit.test("passw0rd");    // true

// More complete password strength check:
// At least 8 chars, contains uppercase, lowercase, and digit
const strongPassword = /^(?=.*[a-z])(?=.*[A-Z])(?=.*d).{8,}$/;
strongPassword.test("weakpass");    // false
strongPassword.test("StrongP4ss"); // true

// Find "foo" only when followed by "bar":
"foobar foobaz".match(/foo(?=bar)/g); // ["foo"] (only the first)

Notice in the password example: multiple lookaheads can be chained at the start of a pattern. Each one checks a different condition at the same position before the main match begins. This is an elegant way to express "AND" conditions without requiring a specific order in the string.

Negative lookahead: (?!...)

Negative lookahead (?!...)asserts that what follows does NOT match the pattern. It's the NOT condition.

// Match "color" but not "colorful":
/color(?!ful)/g.test("the color is nice"); // true
/color(?!ful)/g.test("a colorful design"); // false

// Match file extensions but not .min.js:
const jsFiles = /w+(?!.min).js/;
"app.js".match(jsFiles);     // ["app.js"]
"app.min.js".match(jsFiles); // null

// Practical: match an import that isn't a type import
// (simplified — real TypeScript detection is more complex)
const regularImport = /import(?! type)s+/;

Positive lookbehind: (?<=...)

Positive lookbehind (?<=...)asserts that what precedes the current position matches the pattern. Like lookahead, it's zero-width — the lookbehind content is not included in the match.

// Extract amount after "$" without including "$":
const amount = /(?<=$)[d,]+.?d*/;
"Total: $1,234.56".match(amount); // ["1,234.56"]

// Extract log level from structured log line:
const logLevel = /(?<=[)w+(?=])/;
"[ERROR] Connection failed".match(logLevel); // ["ERROR"]

// Extract value after "key=" in a config string:
const getValue = (key) => new RegExp(`(?<=${key}=)[^&]+`);
"a=1&b=hello&c=3".match(getValue('b')); // ["hello"]

// JavaScript support: ES2018+ (Node 10+, Chrome 62+)
// NOT supported in some older environments or Safari < 16.4

Important caveat: lookbehind is an ES2018 feature in JavaScript. It's supported in all modern browsers and Node.js 10+, but if you need to target older environments (IE11, legacy iOS Safari), you cannot use lookbehind without a polyfill or alternative approach.

Negative lookbehind: (?<!...)

Negative lookbehind (?<!...) asserts that the preceding content does NOT match the pattern. Combine with negative lookahead for powerful NOT conditions on both sides.

// Match digits NOT preceded by a decimal point:
// (i.e., match integer parts but not decimal parts)
const integerPart = /(?<!.)\d+/g;
"3.14 and 42".match(integerPart); // ["3", "42"] not "14"

// Match "http" but not "https":
/http(?!s)/.test("http://example.com");  // true
/http(?!s)/.test("https://example.com"); // false

// Match a word not preceded by an underscore (not a private var):
const publicVar = /(?<!_)[a-z]w+/;

// Find TODO comments that aren't already marked with a ticket:
const unmarkedTodo = /TODO(?!s*[)/;
"// TODO: fix this".match(unmarkedTodo);     // matches
"// TODO [JIRA-123]: fix this".match(unmarkedTodo); // no match

Practical log parsing: combining lookahead and lookbehind

Lookaheads and lookbehinds really shine when parsing structured text where the value you want is surrounded by delimiters you don't want to include. Server log parsing is the canonical example.

// Nginx access log line:
// 192.168.1.1 - - [01/May/2026:14:30:00 +0000] "GET /api/v1/users HTTP/1.1" 200 1234

const logLine = '192.168.1.1 - - [01/May/2026:14:30:00 +0000] "GET /api/v1/users HTTP/1.1" 200 1234';

// Extract status code (number between last space-delimited tokens):
const status = logLine.match(/(?<= )d{3}(?= d+$)/)?.[0]; // "200"

// Extract request path (after method, before HTTP/):
const path = logLine.match(/(?<=GET |POST |PUT |DELETE )/[^s]+/)?.[0];
// "/api/v1/users"

// Extract timestamp (between [ and ]):
const timestamp = logLine.match(/(?<=[)[^]]+/)?.[0];
// "01/May/2026:14:30:00 +0000"

Without lookbehind, extracting the timestamp requires either a capturing group followed by indexing into the match array, or a separate string manipulation step. With lookbehind, the intent is self-documenting: "match everything between brackets."

When to use zero-width assertions vs capturing groups

Zero-width assertions are not always the right choice. If you're already using a capturing group and the surrounding delimiters are consistent, capturing groups are simpler and more broadly supported:

// Lookbehind approach:
const price = "Total: $19.99".match(/(?<=$)[d.]+/)?.[0];

// Capturing group approach (works in all environments):
const price2 = "Total: $19.99".match(/$([d.]+)/)?.[1];

// For compatibility with IE11 or older browsers, prefer capturing groups.
// For code where self-documentation matters more, prefer lookahead/lookbehind.

The rule of thumb: use zero-width assertions when the surrounding context is part of the match condition but not part of the value you want, and when browser/environment support allows. They are the difference between a regex that matches the right thing and a regex that matches the right thing elegantly. For any pattern with more than 2 capturing groups, consider whether some of them could be lookaheads to reduce index-tracking complexity.

Published May 16, 2026 · By the utili.dev Team