DevKitLab Logo DevKitLab
Regex / Lookahead / JavaScript

How Do I Match Something Without Consuming It? Regex Lookahead and Lookbehind

Your regex matches, but match[0] is missing the piece you expected — or your password check needs three rules at once. Both come down to one idea: lookahead and lookbehind inspect the text without consuming it. Here's how zero-width assertions really work, and the handful of things they make easy.

Here’s a small mystery that catches almost everyone the first time. You want the number that sits right before px, so you write \d+(?=px), run it on 12px, and it matches 12. Good. But look closely at what came back — just 12. The px you typed into the pattern isn’t in the result at all: not consumed, not captured, simply absent. If you weren’t expecting that, it feels like the regex quietly ate part of your own pattern.

It didn’t. (?=px) is a lookahead, and lookaheads belong to a small family of regex pieces — assertions — that check the text without taking it. That one idea, “check but don’t take,” is the whole game. Once it clicks, lookahead and lookbehind stop being fiddly syntax you paste off Stack Overflow and become two of the sharpest tools in the kit: pulling a value out without the punctuation around it, dropping text into an exact spot without disturbing it, and enforcing several independent rules at once — a common way to pack several password requirements into a single pattern.

This is the third piece in a series. The first article built a mental model of how a backtracking engine walks through text; the second used that model to explain why some patterns melt your CPU. We lean on the same model here, so if “the engine walks left to right with a cursor” already means something to you, this will move fast.

Zero-width: the one idea that unlocks all of it

Picture the engine the way the first article did: a cursor sitting between characters, marching left to right. Almost every piece of a regex consumes — it matches one or more characters and drags the cursor forward past them. Run \d+ on 12px and it matches 1 then 2, leaving the cursor parked just before p. Those two characters are now “spent”; the rest of the pattern picks up from where they left off.

An assertion does something categorically different. It looks at the text around the cursor, decides pass or fail, and then leaves the cursor exactly where it was. It matches a position, not characters — which is why the jargon calls it zero-width. (?=px) at the cursor asks a yes/no question — “do the next two characters spell px?” — and when the answer is yes, it succeeds having advanced nothing at all. The px was inspected and handed straight back; it never got consumed, so it never lands in match[0]. The phantom characters were never really missing. They were just never taken.

Here’s the reassuring part: you’ve almost certainly been using zero-width assertions for years without the vocabulary. ^ and $ don’t match a character — they match “the position at the start / end of the string.” \b matches “the position between a word character and a non-word character.” Lookahead and lookbehind are that same trick with the doors thrown open: instead of the fixed handful of positions the engine ships with, you get to put any sub-pattern inside the condition. (?=px) is just \b grown up enough to check for whatever you want.

The four assertions

There are exactly four, split two ways: looking ahead (at what follows the cursor) or behind (at what precedes it), and asserting the thing is present (positive) or absent (negative).

AssertionNamePasses when…
(?=...)positive lookaheadwhat’s immediately ahead matches ...
(?!...)negative lookaheadwhat’s immediately ahead does not match ...
(?<=...)positive lookbehindwhat’s immediately behind matches ...
(?<!...)negative lookbehindwhat’s immediately behind does not match ...

A concrete example of each, all zero-width — notice that in every case the asserted text stays out of the match:

\d+(?=px)        "12px"      → matches 12   (a run of digits that is followed by "px")
foo(?!bar)       "foobaz"    → matches foo  ("foo" not followed by "bar"; fails on "foobar")
(?<=\$)\d+       "$100"      → matches 100  (digits preceded by a "$")
(?<!\$)\d+       "€100"      → matches 100  (digits NOT preceded by a "$")

The negative pair is worth reading twice, because “matches when the thing is absent” is easy to misjudge. foo(?!bar) doesn’t mean “foo followed by something that isn’t bar” — it means “foo, and at this position bar does not begin.” On foobar it fails outright; on foo at the end of the string it succeeds, because there’s nothing there to be bar. Absence includes “nothing at all.”

If you want these to stop being abstract, paste each one into the regex tester and watch the highlight. The tester shades only the part that’s actually consumed — so the asserted text stays un-highlighted right next to it. That contrast, the condition sitting there uncolored while only the real match lights up, is the single clearest picture of “zero-width” you’ll get.

Putting zero-width to work

Three everyday jobs fall straight out of “check but don’t take” — one each for match, replace, and split — and together they’re the reason lookaround earns its place.

Return a value without its delimiters

Say you’re pulling the link target out of <a href="/products/12">. The obvious pattern is a capturing group:

href="([^"]*)"

That works, but the whole match includes the href=" and the closing ", and the value you actually want is buried in group 1 — you have to reach in and read match[1]. Lookaround lets the whole match be exactly the value:

(?<=href=")[^"]*(?=")

Read it left to right: the lookbehind asserts “href=" sits immediately behind me,” the lookahead asserts “a " sits immediately ahead of me,” and the only consuming part in the middle, [^"]*, grabs the value itself. The quotes are conditions, not content, so they never enter the match. match[0] comes back as /products/12, clean, no slicing afterward. (This is the same two-link snippet the first article used as its running example — there we solved it with a capturing group; lookaround is the version where the match is the answer.)

Insert at a spot without disturbing it

This is where zero-width becomes almost magical. The classic task: turn 1234567 into 1,234,567. You don’t want to replace any digits — you want to slip a comma into the gaps between them. A zero-width match is precisely a gap:

"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",")   // → "1,234,567"

Nothing here consumes a single character. (?=(\d{3})+(?!\d)) matches each position that has a whole number of three-digit groups to its right and no stray digit after them — i.e., exactly the spots where a thousands separator belongs. The \B (a non-boundary, the opposite of \b) keeps a comma from landing at the very start. Because every match is empty, replace doesn’t remove anything; it just drops a , into each matched gap. Paste the pattern into the regex tester and — since a zero-width match has nothing to paint — the proof shows up in the match list rather than the highlight: each hit is reported at its position with Length: 0, a match that occupies a gap between characters instead of any characters themselves. Seeing that Length: 0 sit between two digits is what turns “match a position” from a phrase into a thing you can point at.

Split without eating the separator

split normally deletes whatever it splits on — "a-b-c".split("-") throws the dashes away. But split on a zero-width match and there’s nothing to delete, so the boundary stays put. That’s how you break camelCase into words without losing a single letter:

"camelCase".split(/(?=[A-Z])/)   // → ["camel", "Case"]

(?=[A-Z]) matches the empty position just before each capital. split cuts there, but since the match consumed nothing, the capital rides along with the chunk after it. Same zero-width idea, a third string method — match pulled a value out, replace slipped one in, split cut between them, and none of the three disturbed a character it wasn’t meant to.

The power move: stacking lookaheads for “and”

Now the use that alone justifies learning lookahead. You want a password rule: at least eight characters, and it must contain a digit, a lowercase letter, and an uppercase letter — in any order. Try expressing “contains a digit somewhere and a lowercase somewhere and an uppercase somewhere, arranged however” as a normal left-to-right pattern and you’ll drown, because a single pass has to commit to some order. Lookahead dissolves the problem:

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

The mechanism is beautiful once you see it, and it’s pure zero-width. After ^, the cursor is at position 0. (?=.*\d) scans ahead — .* runs to the end, then backtracks until it finds a digit anywhere in the string — and on success it resets the cursor to 0, because it consumed nothing. Then (?=.*[a-z]) runs, again from position 0. Then (?=.*[A-Z]), again from 0. Each lookahead is an independent yes/no test over the whole string, and because none of them move the cursor, they stack up as a logical AND: “there is a digit, and there is a lowercase, and there is an uppercase.” Only when all three have passed does .{8,}$ finally consume — enforcing the length and running to the end.

Two things fall out of this that make it endlessly practical. Order doesn’t matter — the lookaheads all test from the same spot, so you can list the conditions in any sequence. And you add or drop a rule just by adding or removing a lookahead: forbid whitespace with (?!.*\s), require a symbol with (?=.*[!@#$%^&*]). Each clause is a self-contained assertion about the whole string, snapped on independently.

When is this actually worth it? In application code, honestly, three separate .test() calls are usually clearer to read, and you should reach for those. The stacked-lookahead form earns its keep when you’re allowed one pattern and no code around it — an HTML <input pattern="…"> attribute, a JSON Schema pattern, a validation rule in a config file or a framework that accepts a single regex. There, “several independent conditions in one expression” isn’t a party trick — it’s often the only way to say it at all.

One caveat that bites people: . doesn’t match a newline by default, so .*\d only sees up to the first line break. If your input can span lines and you want the rule to apply across the whole thing, add the s (dotall) flag or replace . with something explicit like [\s\S]. (And what .{8,} actually counts depends on flags: without u, each . is one UTF-16 code unit — an emoji built from a surrogate pair counts as two — while with u, . matches a whole code point and that emoji counts as one. Neither is a grapheme, the character a user perceives, so an emoji-laden password can tally up differently than you’d expect. The same Unicode wrinkle the first article covered.)

JavaScript specifics and a couple of gotchas

Lookahead has been in JavaScript forever. Lookbehind is younger — it arrived in ES2018, and it was the one holdout in browser support for a while (Safari was last to the party), so it’s universally available in modern runtimes but worth a glance if you must support something ancient.

Where JavaScript is unusually generous is variable-length lookbehind. Some engines insist a lookbehind be a fixed width — Python’s re is the one you’re most likely to bump into — because matching backwards is simpler when you know exactly how far back to look. JavaScript lets the lookbehind be any length: (?<=\w+), (?<=\d{1,3},), (?<=<[a-z]+>) are all legal. If you’ve hit “lookbehind requires fixed-width pattern” errors elsewhere and assumed it’s a universal rule, JS quietly lifts it.

There is a wrinkle worth knowing, though: JavaScript matches a lookbehind right to left, from the cursor backwards. Most of the time you won’t notice, but a quantifier or a capturing group inside a lookbehind resolves in that reversed direction — so what a group captures can differ from the left-to-right reading you’d expect from the mirror-image lookahead. When a lookbehind with an inner group surprises you, this is usually why; test it rather than assume. (This connects back to the second article’s point that which engine you’re on changes what’s possible.)

Two smaller things worth knowing:

  • Capture groups inside an assertion still capture. /(?<=(\d+))px/ fills group 1 with the digits even though the lookbehind itself contributes nothing to match[0]. Handy, occasionally surprising.
  • Zero-width unlocks overlapping matches. Normal matching consumes, so two matches can never overlap. But wrap the real work in a lookahead and each match becomes zero-width — and because a global iteration (matchAll, or the g flag) steps forward by one position after an empty match, the capture inside the lookahead harvests every overlapping window in turn: /(?=(\d{2}))/g on 1234 yields group 1 = 12, then 23, then 34. There’s no other clean way to get overlapping matches out of a single matchAll.

One caution: an assertion is still real regex

It’s tempting to treat lookaround as a lightweight annotation, but whatever you put inside (?=...) runs under the exact same backtracking rules as everything else. An assertion doesn’t add exponential risk by itself — the several .* lookaheads in a password check are each an independent linear scan, which is why those patterns are generally fine. But the moment you nest a self-overlapping repetition inside an assertion, you’ve smuggled a bomb into a condition, and it’s every bit as catastrophic as it would be out in the open. The second article’s ReDoS rules don’t stop at the parenthesis of a lookahead. Test assertion-heavy patterns the same way you’d test any other — the tester’s ReDoS check looks inside them too.

One precise point for the curious. While a lookahead is being evaluated, it backtracks internally like any other sub-pattern. But the moment it succeeds, the engine treats it as settled: if a later part of the pattern fails, the engine won’t step back into the lookahead to try a different internal match, and whatever a group inside it captured is frozen at the instant of success. Assertions look, commit, and move on — which is occasionally the difference between the capture you got and the one you assumed you’d get.

When a capturing group is the better call

Lookaround isn’t always the right answer, and reaching for it on reflex is its own small mistake.

  • If you just need the value in code, a capturing group is usually clearer. href="([^"]*)" and reading match[1] is more obvious to the next person than a lookbehind-and-lookahead sandwich, and it works in every engine, including ones without lookbehind at all. Save lookaround for when you specifically need match[0] itself to be the value — a replace with $&, a split, or a tool or API that only hands you the whole match.
  • Don’t parse real HTML with any of this. The href example works because it’s a small, controlled string. Point a regex at arbitrary markup — attributes in any order, single or double quotes, stray whitespace, comments — and it will quietly get things wrong. In a browser, read the DOM (element.getAttribute("href")); on a server, use a real HTML parser. Regex is for the shape of a string you already understand, not for grammar.

A cheat sheet you can keep

  1. Zero-width is the whole idea. An assertion tests a position and leaves the cursor put; its contents are inspected, never consumed, so they never appear in match[0].
  2. The four: (?=) / (?!) look ahead; (?<=) / (?<!) look behind; the ! versions pass when the thing is absent — and “absent” includes “nothing there at all.”
  3. Return a value without its delimiters: (?<=open)value(?=close) makes the whole match the value itself.
  4. Act at a spot without disturbing it: a zero-width match in replace inserts without deleting (thousands separators); in split it cuts without eating the separator (splitting camelCase) — because nothing was consumed, nothing is lost.
  5. Enforce several independent rules: stack lookaheads right after ^; they all test from the same spot and combine as AND, in any order.
  6. JavaScript lets lookbehind be variable-length — don’t assume the fixed-width restriction you may know from other engines.
  7. Whatever’s inside an assertion still backtracks — keep the ReDoS rules in mind, and test it.

Three articles, one machine. The first taught the cursor that walks and backtracks; the second showed what happens when it backtracks too much; and lookaround, it turns out, is just that same cursor being asked to look without stepping. Anchors were always doing it. Now you can point it at anything — and the payoff is the same quiet trick each time: decide what’s a condition and what’s content, and let the match be exactly, only, the thing you actually wanted.