Regular expressions usually match a run of characters and hand them back to you. But sometimes the thing you want isn’t defined by what it is so much as by what sits next to it. You want the number that comes right before a currency sign, or the word that is not preceded by a @. The characters around your target matter, yet you don’t want them in the result.
That’s the job of lookahead and lookbehind. Together they’re called lookaround, and they let you attach a condition — “only if followed by this” or “only if preceded by that” — without pulling those neighboring characters into the match.
To ground the idea, take the string 3 candles cost 40€. Say you want the price: a number that is immediately followed by a € sign. The 3 should be ignored; only 40 qualifies.
3 candles cost 40€↑ matched↑ tested, not consumed
The price is the digit run that sits right before €. The € itself is a condition, not part of what we extract.
Lookahead
The syntax is pattern:X(?=Y). Read it as: “find pattern:X, but keep it only if pattern:Y comes right after.” Both pattern:X and pattern:Y can be any pattern you like.
For an integer that is followed by subject:€, the expression is pattern:\d+(?=€):
let str = "3 candles cost 40€";alert( str.match(/\d+(?=€)/) ); // 40// the number 3 is ignored — nothing (well, a space) follows it, not €
The key detail: the lookahead pattern:(?=...) is a test, not a capture. The subject:€ never lands in the result. The match is the bare string match:40.
Edit the pattern below and watch which parts of the string light up. Try removing the (?=€) to see 3 get matched too, or change € to \s to hunt for numbers followed by a space instead:
const sample = "3 candles cost 40€, 2 lanterns cost 18€";
const patEl = document.getElementById("pat");
const flagEl = document.getElementById("flags");
const out = document.getElementById("out");
const res = document.getElementById("res");
const err = document.getElementById("err");
function esc(s) {
return s.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));
}
function run() {
err.textContent = "";
const base = flagEl.value.replace(/g/g, "");
let re;
try {
re = new RegExp(patEl.value, base + "g");
} catch (e) {
err.textContent = "Invalid regex: " + e.message;
out.textContent = sample;
res.textContent = "";
return;
}
let html = "", last = 0, m, guard = 0;
const found = [];
while ((m = re.exec(sample)) !== null) {
if (guard++ > 2000) break;
const start = m.index, end = m.index + m[0].length;
html += esc(sample.slice(last, start)) + "<mark>" + esc(m[0]) + "</mark>";
last = end;
found.push(m[0]);
if (m[0] === "") re.lastIndex++;
}
html += esc(sample.slice(last));
out.innerHTML = html;
res.textContent = found.length
? "Matches: " + found.map(x => JSON.stringify(x)).join(", ")
: "No matches";
}
patEl.addEventListener("input", run);
flagEl.addEventListener("input", run);
run();
Here’s how the engine thinks about pattern:X(?=Y). It locates a candidate pattern:X, then peeks at the position right after it and asks, “is there a pattern:Y here?” If yes, pattern:X is accepted. If no, the engine throws that candidate away and keeps scanning. The peek never moves the read position forward — that’s why the assertion is often described as “zero-width.”
1. match \d+ → “40”→2. peek: is next char €?→yes → keep “40”
The read cursor stays put after phase 2 — the € stays in the string, out of the result.
Lookahead evaluates in two phases at the same spot: consume X, then look ahead at Y without advancing.
You can chain more than one test at the same position. pattern:X(?=Y)(?=Z) reads as:
Find pattern:X.
Check that pattern:Y follows right after pattern:X. Skip if it doesn’t.
Check that pattern:Zalso follows right after pattern:X. Skip if it doesn’t.
If both pass, pattern:X matches. Otherwise, keep searching.
So you’re demanding that pattern:X be followed by pattern:Yandpattern:Z at once, both measured from the same starting point. That only works when pattern:Y and pattern:Z can be true simultaneously — they can’t be mutually exclusive.
For example, pattern:\d+(?=\s)(?=.*40) looks for a digit run that is followed by a space (pattern:(?=\s)) and that has 40 somewhere later in the string (pattern:(?=.*40)):
In this string, 3 fits both tests: a space sits right after it, and 40 appears further along. 40 itself fails the first test, since € follows it, not a space.
Negative lookahead
Now flip the goal. From the same kind of string, you want the quantity, not the price — a number that is not followed by subject:€.
That’s negative lookahead. The syntax is pattern:X(?!Y): “match pattern:X, but only if pattern:Y does not come right after it.”
let str = "5 candles cost 80€";alert( str.match(/\d+\b(?!€)/g) ); // 5 (the price 80 is not matched)
The pattern:\b there is a word boundary. Without it, pattern:\d+ would happily match 8 inside 80 — since 0 follows 8, not €, the negative test passes and you’d get a wrong partial match. The boundary forces the digit run to end at a real edge, so 80 is tested as a whole and correctly rejected because € sits right after it.
X(?=Y) positive
keep X only when Y is next
\d+(?=€) → 40
X(?!Y) negative
keep X only when Y is not next
\d+\b(?!€) → 5
Positive vs negative lookahead: same X, opposite verdicts on what follows.
Lookbehind
Lookahead sets a condition on what comes after. Lookbehind is the mirror image: it sets a condition on what comes before. It matches a pattern only when the right thing precedes it.
Positive lookbehind — pattern:(?<=Y)X matches pattern:X, but only if pattern:Y is directly before it.
Negative lookbehind — pattern:(?<!Y)X matches pattern:X, but only if pattern:Y is not directly before it.
Notice the assertion sits ahead of pattern:X in the pattern, since “before” is to the left.
Switch the currency to US dollars, where the sign leads the number. To grab the amount in $40, use pattern:(?<=\$)\d+ — digits preceded by subject:$:
let str = "3 candles cost $40";// the dollar sign is escaped as \$ because $ is a special regexp characteralert( str.match(/(?<=\$)\d+/) ); // 40 (the lone 3 is skipped)
And for the quantity — a number not preceded by subject:$ — reach for negative lookbehind pattern:(?<!\$)\d+:
let str = "5 candles cost $80";alert( str.match(/(?<!\$)\b\d+/g) ); // 5 (the price 80 is not matched)
Here the assertion reads leftward. Start with (?<=\$)\d+ to grab prices, then flip it to (?<!\$)\b\d+ to grab the quantities instead — the same string, opposite verdict on what precedes each number:
const sample = "3 candles cost $40, and 2 lanterns cost $18";
const patEl = document.getElementById("pat");
const flagEl = document.getElementById("flags");
const out = document.getElementById("out");
const res = document.getElementById("res");
const err = document.getElementById("err");
function esc(s) {
return s.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));
}
function run() {
err.textContent = "";
const base = flagEl.value.replace(/g/g, "");
let re;
try {
re = new RegExp(patEl.value, base + "g");
} catch (e) {
err.textContent = "Invalid regex: " + e.message;
out.textContent = sample;
res.textContent = "";
return;
}
let html = "", last = 0, m, guard = 0;
const found = [];
while ((m = re.exec(sample)) !== null) {
if (guard++ > 2000) break;
const start = m.index, end = m.index + m[0].length;
html += esc(sample.slice(last, start)) + "<mark>" + esc(m[0]) + "</mark>";
last = end;
found.push(m[0]);
if (m[0] === "") re.lastIndex++;
}
html += esc(sample.slice(last));
out.innerHTML = html;
res.textContent = found.length
? "Matches: " + found.map(x => JSON.stringify(x)).join(", ")
: "No matches";
}
patEl.addEventListener("input", run);
flagEl.addEventListener("input", run);
run();
$40(?<=$)\d+ — test the $ on the left, keep 40
40€\d+(?=€) — test the € on the right, keep 40
Lookbehind reads to the left of X; lookahead reads to the right. Neither is included in the result.
Capturing groups
By default the text inside a lookaround stays out of the result. In pattern:\d+(?=€), the subject:€ isn’t captured — that’s the whole point, you’re matching a number and merely asserting a € follows.
But sometimes you do want to keep part of the lookaround. You can, by wrapping that part in its own capturing group pattern:(...). The group’s text then shows up as an extra entry in the match array, even though the assertion itself still doesn’t advance the main cursor.
Here the currency sign pattern:(€|kr) is captured alongside the amount:
let str = "3 candles cost 40€";let regexp = /\d+(?=(€|kr))/; // extra parentheses around €|kralert( str.match(regexp) ); // 40, €
The result array holds match:40 at index 0 (the full match) and match:€ at index 1 (group 1). The full match is still just 40 — the € rides along only because you asked for it in a group.
See the array split apart below. The pattern starts as \d+(?=(€|kr)) — index [0] holds the consumed 40, while index [1] holds the € grabbed by the group inside the lookahead. Drop the inner parentheses to \d+(?=€|kr) and [1] disappears, proving the group is what surfaces the character, not the assertion:
const sample = "3 candles cost 40€";
const patEl = document.getElementById("pat");
const cells = document.getElementById("cells");
const err = document.getElementById("err");
function run() {
err.textContent = "";
cells.innerHTML = "";
let re;
try {
re = new RegExp(patEl.value);
} catch (e) {
err.textContent = "Invalid regex: " + e.message;
return;
}
const m = sample.match(re);
if (!m) {
cells.innerHTML = '<div class="none">No match</div>';
return;
}
for (let i = 0; i < m.length; i++) {
const div = document.createElement("div");
div.className = i === 0 ? "cell" : "cell grp";
const label = i === 0 ? "match[0] — full match" : "match[" + i + "] — group";
const val = m[i] === undefined ? "undefined" : JSON.stringify(m[i]);
div.innerHTML = '<div class="idx">' + label + '</div><div class="val">' + val + "</div>";
cells.appendChild(div);
}
}
patEl.addEventListener("input", run);
run();
match[0] — full match
“40”
match[1] — group inside lookaround
“€”
A capturing group inside a lookaround adds a value to the result array, but the full match [0] is still only the consumed part.
Doing it without lookaround
Lookaround is convenient, but it isn’t magic — for simple cases you can reach the same result by hand: match broadly, then filter by context in a loop.
The tools for that are str.match (without the pattern:g flag) and str.matchAll (always). Both return match objects that carry an index property telling you exactly where the match sits in the text. With that index you can inspect the surrounding characters yourself and decide whether to keep the match.
let str = "3 candles cost 40€";for (let m of str.matchAll(/\d+/g)) { // m.index is where the digits start; check the char right after the match let after = str[m.index + m[0].length]; if (after === "€") { alert(m[0]); // 40 }}
This works, but it’s more code and more room for off-by-one mistakes. For anything beyond the trivial, a lookaround expresses the intent in one place and lets the engine handle the bookkeeping.
Summary
Lookaround lets you match something based on the context around it — before, after, or both — without consuming that context.
Lookahead tests what comes after your target.
Lookbehind tests what comes before it.
Both are zero-width: the tested text is checked but not included in the match (unless you deliberately wrap part of it in a capturing group).
For simple contexts you can match everything and filter by index in a loop, but lookaround is usually cleaner.