Anchors, boundaries & lookaround
Learn JavaScript regex anchors, word boundaries, lookahead, lookbehind, and inline modifiers by matching positions instead of characters.
- 01Find the gaps a regex checksPoint to the gaps where
^,$,\b, and\Bcan match, and know what the planned\A,\z, and\Zwould do. - 02Check a whole valueUse anchors so a regex checks the whole form field, not just a piece of text inside it.
- 03Read lookaroundsExplain lookahead and lookbehind, both positive and negative, what round brackets inside them save, and inline modifiers.
Positions, not characters
A regular expression, or regex, is a pattern for finding text. Most regex parts match characters. For example, \d matches one digit, and cat matches the three letters c, a, t.
This lesson is about a different kind of part. These parts don't match a character. They check a position: a gap between two characters, or the gap at the start or end of the text.
While a regex runs, JavaScript moves through the text from left to right. The program that does this is called the regex engine. A position check asks: "Is the engine standing in the right gap?"
A zero-width assertion is a regex part that checks a position. It uses up no characters, so it adds nothing to the match. That is why it is called "zero-width".
This lesson covers three kinds:
- Anchors, like
^and$: the start or end of the text. - Word boundaries, like
\b: the edge of a word. - Lookarounds, like
(?=...): what comes just before or just after the gap.
^ checks the gap before the first letter. $ checks the gap after the last letter. Neither one uses up a letter.Picture a guard standing in a doorway. People walk through the doorway. The guard checks each badge, but the guard does not walk with them. The guard is never one of the people who went through.
- In real life: The guard stands in the doorway between two rooms
- In JavaScript: A zero-width assertion stands in the gap between two characters
- In real life: The guard checks each person's badge
- In JavaScript: A lookahead or lookbehind checks the text next to the gap
- In real life: The guard does not join the group that walks through
- In JavaScript: The assertion adds no characters to the match
This lesson is only about position checks. Other lessons cover the rest of regex:
- How to write a regex and its flags: Patterns & flags.
- Sets of characters like
\dand[a-z]: Character classes. - Repeat marks like
+and{3}: Quantifiers. - Round-bracket groups: Groups & backreferences.
This table lists the tools in this lesson. Read each row from left to right: the tool, the question it asks, and whether it uses up text.
| Tool | What question does it ask? | Does it use up text? |
|---|---|---|
| Anchors | Am I at the start or end of the text, or of a line? | No. |
| Word boundaries | Am I at the edge of a word? | No. |
| Lookaround | Does the text just before or just after this gap match another pattern? | No. |
| Inline modifiers | Should a flag apply only inside this group? | The modifier itself uses up nothing. The parts inside its group can still use up text. |
^, $ and multiline mode
STEP THROUGH^ and $ are the anchors you will meet most often. A flag is a letter after the closing slash that changes how the regex works. The m flag turns on multiline mode.
- Without
m:^means only the start of the whole text, and$means only the end of the whole text. - With
m:^also means the start of each line, and$also means the end of each line.
In the code below, \n is a line break: one character that starts a new line. Step through it with and without m.
Step through the same code with and without the m flag. Watch where ^ and $ are allowed to match.
script
const pattern = /^beta$/m;const match = pattern.exec(text); console.log(match ? match.index : null);console.log(match ? match[0] : "no match");m, ^ and $ only fit at the two ends of the whole text, so beta has no match. With m, they also fit at the start and end of each line.This table compares the two modes. Read each row from left to right: what you compare, what happens without m, and what happens with m.
| What to compare | Without m | With m |
|---|---|---|
^ | Matches only at index 0, the start of the whole text. | Also matches just after each line break. |
$ | Matches only at the very end of the whole text. | Also matches just before each line break. |
A last \n | /end$/.test("end\n") is false, because $ is not at the very end. | /end$/m.test("end\n") is true, because $ can stand just before the line break. |
| When to use it | To check a whole value, like a form field. | To check each line on its own, like the lines of a log file. |
Some text ends with one last line break. Other regex languages, like Python and Perl, let $ match just before that last line break. JavaScript does not.
In JavaScript, $ without m matches only the very end. So if the text ends with \n, you have two choices:
- Put the
\nin your pattern. - Turn on
m, if line ends are what you want.
$ and a last line breakconsole.log(/end$/.test("end\n"));console.log(/end\n$/.test("end\n"));console.log(/end$/m.test("end\n"));Anchors also decide whether a regex finds text or checks the whole text:
/admin/findsadminanywhere inside the text./^admin$/passes only if the whole text is exactlyadmin.
Checking that the whole text follows a rule is called validation. For example, you validate a form field before you save it.
const value = "say admin now";console.log(/admin/.test(value));console.log(/^admin$/.test(value));console.log(/^admin$/.test("admin")); const lines = "admin\nsay admin now";console.log(/^admin$/.test(lines));console.log(/^admin$/m.test(lines));Look at the last two logs. The text lines has two lines, and the first line is admin.
- Line 7 has no
m, so the whole text must beadmin. It is not, so the result isfalse. - Line 8 has
m, so one matching line is enough. The result istrue.
So leave m off when you validate a form field. A user could type admin on one line and anything else on the next line, and it would pass. Turn m on only when each line is its own record, like the lines of a log file.
Word boundaries and planned buffer boundaries
INTERACTIVE\b is a word boundary. It matches a gap where a word character is on one side and a non-word character (or the start or end of the text) is on the other side.
For \b, a word character is only one of these:
- a letter from
AtoZoratoz - a digit from
0to9 - the underscore,
_
The opposite is \B. It matches every gap that is not a word boundary. Try both in the playground below.
const text = "hi\ncafé cat\nend\n";console.log([...text.matchAll(/^/gm)].map((m) => m.index));console.log([...text.matchAll(/$/gm)].map((m) => m.index));console.log([...text.matchAll(/\b/g)].map((m) => m.index));With m, ^ and $ also show up at each line break, so you can check each line on its own.
The short list of word characters causes trouble with real names. A person reads café as one word. But é is not on the list, so \b sees a word edge between f and é. The u flag does not change this.
For words in any language, use lookarounds with \p{L}. With the u flag, \p{L} means "any letter in any language". You met it in the Character classes lesson. Lookarounds are explained later in this lesson.
\b only knows A to Z; \p{L} knows every letterconsole.log(/\bcat\b/.test("concatenate"));console.log(/\bcat\b/.test("a cat"));console.log(/\bcafé\b/u.test("café"));console.log(/(?<!\p{L})café(?!\p{L})/u.test("café"));console.log(/\Bend/.test("weekend"));Here is what each log shows:
- Line 1 gives
false. Insideconcatenate, the letterscathave letters on both sides, so there is no word edge. - Line 2 gives
true. Ina cat, the wordcatstands on its own. - Line 3 gives
false.\bdoes not countéas a word character. - Line 4 gives
true. The lookarounds check that no letter of any language comes before or aftercafé. - Line 5 gives
true. Inweekend, the gap beforeendhas letters on both sides, so it is not a word edge, and\Bmatches it.
TC39 is the group that adds new features to JavaScript. A proposal is a planned feature that is not finished yet. The RegExp Buffer Boundaries proposal is at Stage 3, close to finished. Node 22 and Chrome 154 do not support it yet.
Here, the "buffer" means the whole text. The proposal adds three new anchors:
\A: the start of the whole text.\z: the very end of the whole text.\Z: the end, or just before one last line break.
\A, \z, and \Z (does not run yet)JavaScript// Planned syntax: Node 22 and Chrome 154 can't run this yet.const log = "status: ok\n";console.log(/\Astatus: ok\Z/u.test(log)); // would allow one last line breakconsole.log(/\Astatus: ok\z/u.test(log)); // would need the very endHere is what works today:
- For most whole-text checks, use
^and$withoutm. - If you need an anchor that no flag can change, use the lookarounds on lines 5 and 6 below.
[\s\S]means "any character". So "no character before" means the start, and "no character after" means the very end.
const exact = "status: ok";const withFinalNewline = "status: ok\n"; console.log(/^status: ok$/.test(exact));console.log(/(?<![\s\S])status: ok(?![\s\S])/.test(exact));console.log(/(?<![\s\S])status: ok(?![\s\S])/.test(withFinalNewline));Lookahead: check what comes next
STEP THROUGHA lookahead checks the text just after the current gap. It comes in two kinds:
- Positive lookahead,
(?=...): the next text must match the pattern inside. - Negative lookahead,
(?!...): the next text must not match the pattern inside.
After the check passes, the engine stays in the same gap. It has not moved past any characters.
const text = "ababa";const starts = [...text.matchAll(/(?=aba)/g)].map((match) => match.index);console.log(starts.join(","));console.log(text.replace(/(?=aba)/g, "|")); const captured = /(?=(aba))/.exec(text);console.log(captured[0].length);console.log(captured[1]); const noAdmin = /^(?!.*\badmin\b).+$/;console.log(noAdmin.test("admin user"));console.log(noAdmin.test("regular user"));Here is what each part shows:
- Line 3 prints
0,2. The lookahead finds two gaps inababawhereabacomes next: index 0 and index 2. - Line 4 prints
|ab|aba.replaceputs a|in each of those gaps. No letters are removed, because the lookahead matched no letters. - Lines 6 to 8 show that round brackets inside a positive lookahead still save text. The whole match is empty, so its length is
0. But group 1 savedaba. - Lines 10 to 12 use a negative lookahead as a "must not contain" rule.
(?!.*\badmin\b)fails if the wordadminappears anywhere. Soadmin usergivesfalse, andregular usergivestrue.
In the last example, the lookahead checks from the start first. Only after the check passes does .+ use up the text.
Lookaheads are great for password rules. Several lookaheads can all check the same password from the start, one rule each. Step through it below.
Several lookaheads check the same password before .+ uses it up. Step through the three passwords.
script
console.log(strong.test("desk-2"));console.log(strong.test("Desk 2026"));console.log(strong.test("Desk-2026"));Lookaheads can also add commas to a long number, like 1,234,567,890. The pattern finds each gap where the digits to the right come in full groups of three:
\Bmeans "not a word edge". It stops a comma at the very start.(?=(\d{3})+checks that one or more groups of three digits come next.(?!\d))checks that no extra digit comes after those groups, so the groups end exactly at the last digit.
\B and a lookaheadconst value = "1234567890";console.log(value.replace(/\B(?=(\d{3})+(?!\d))/g, ","));Lookbehind: check what came before
PRACTICALA lookbehind checks the text just before the current gap. It also comes in two kinds:
- Positive lookbehind,
(?<=...): the text before must match the pattern inside. - Negative lookbehind,
(?<!...): the text before must not match the pattern inside.
All current browsers support lookbehind: Chrome, Firefox, Edge, and Safari. Safari added it in version 16.4, so only older Safari versions lack it.
const receipt = "Tea $3.50, mug $12, tax 0.90";const prices = [...receipt.matchAll(/(?<=\$)\d+(?:\.\d{2})?/g)].map((match) => match[0]);console.log(prices.join(", "));$ comes just before the number. The $ is not part of the match, so you get 3.50.The lookbehind checks the dollar sign but does not use it up. So the match holds only the number. You don't need an extra group to cut the $ off. The number 0.90 has no $ before it, so it is skipped.
const strong = /^(?=.{8,}$)(?=.*[A-Z])(?=.*\d)(?!.*\s).+$/;console.log(strong.test("desk-2"));console.log(strong.test("Desk 2026"));console.log(strong.test("Desk-2026"));passes
^ and $ make the rules apply to the whole password. The lookaheads check the length, a capital letter, a digit, and no spaces. Only then does .+ use up the text.
Inline modifiers: flags for only one group
ES2025A flag like i (ignore upper and lower case) usually applies to the whole regex. An inline modifier turns a flag on or off for just one group:
(?i:...)turnsion inside the group.(?-i:...)turnsioff inside the group.(?m:...)turnsmon inside the group.
Inline modifiers were added in ES2025, the 2025 edition of JavaScript. Not every place supports them yet:
- Current Chrome and Firefox support them.
- Safari does not yet, says MDN's browser support table.
- Node 22 stops with the error "Invalid group".
// ES2025 inline modifiers. Chrome 154 and Firefox can read this; Node 22 and Safari can't.const route = /^(?i:api)-(?-i:v1)$/i;console.log(route.test("API-v1"));console.log(route.test("API-V1"));checking this browserchecking this browserThe page waits until it is in your browser before it tests anything.
The older way below shows why this feature helps. A normal i flag makes the whole pattern ignore case. It can't ignore case for api while keeping v1 exact.
const route = /^api-v1$/i;console.log(route.test("API-v1"));console.log(route.test("API-V1")); // can't keep only v1 exactPatterns you will really write
SORT ITYou will use these position checks in everyday code:
- to check that a whole value is valid, not just that it contains something
- to leave a prefix like
$out of the text you pull out - to insert text into gaps, like commas, without removing any characters
This table shows common jobs. Read each row from left to right: the job, the pattern, and why it works.
| What job? | What pattern? | Why does it work? |
|---|---|---|
| Check a whole form field | /^admin$/ | It rejects say admin now. The whole value must be exactly admin. |
| Find lines in a log | /^ERROR:/gm | With m, ^ matches at the start of each line, so it finds every line that starts with ERROR:. |
| Password rules | ^(?=.{8,}$)(?=.*\d).+$ | Each lookahead checks one rule on the same password before .+ uses it up. |
| Highlight whole words | \bcat\b, or lookarounds with \p{L} | \b works for words made of A to Z. Use \p{L} lookarounds for words in any language. |
| Get prices | (?<=\$)\d+(?:\.\d{2})? | The lookbehind checks for the $ but keeps it out of the match. |
^\b\d+(?<=\$)\Aadmin
Sort each regex part by what it does. A part that only exists in a proposal does not run in today's engines.
Common misconceptions
- "
$always matches before a last line break." Not in JavaScript withoutm. Other regex languages do this, so test advice before you copy it. - "
^...$always checks the whole value." Only whenmis off, and only if your pattern covers every allowed character. - "
\bfinds the edge of any word." It only knows the letters A to Z, digits, and_. Words likecaféneed lookarounds with\p{L}. - "A lookaround uses up the text it checks." It does not. Only the parts after it decide what ends up in the match.
- "New regex syntax is safe if that code never runs." JavaScript reads a regex literal before any code runs, so new syntax can crash the whole file. Keep new syntax in a string and test for support first.
This table lists all four lookarounds. Read each row from left to right: how you write it, its name, what it checks, and a common use.
| How you write it | Name | What does it check? | Common use |
|---|---|---|---|
(?=...) | Positive lookahead | The text just after the gap must match. It is checked, not used up. | Password rules, and commas before groups of three digits. |
(?!...) | Negative lookahead | The text just after the gap must not match. | Reject spaces, or stop a run of digits. |
(?<=...) | Positive lookbehind | The text just before the gap must match. It stays out of the result. | Get numbers that come after a $. |
(?<!...) | Negative lookbehind | The text just before the gap must not match. | Whole-word checks for any language, with \p{L}. |
Practice exercises
5 EXERCISES$ with a last line breakRead the three tests. Type the three logged values in order.
console.log(/end$/.test("end\n"));
console.log(/end\n$/.test("end\n"));
console.log(/end$/m.test("end\n"));The logs are false, true, and true. Line 1: $ without m does not match before the last line break. Line 2: the pattern includes the \n. Line 3: with m, $ can stand just before a line break.
Which of the two checks treats café as one whole word?
console.log(/\bcafé\b/u.test("café"));
console.log(/(?<!\p{L})café(?!\p{L})/u.test("café"));The second check gives true for café as a whole word. The first gives false, because \b does not count é as a word character.
Predict the string that prints.
const value = "1234567";
console.log(value.replace(/\B(?=(\d{3})+(?!\d))/g, ","));A comma goes into the two gaps that have full groups of three digits after them. The output is 1,234,567.
Type the two prices that come back, in order.
const receipt = "Tea $3.50, mug $12, tax 0.90";
const prices = [...receipt.matchAll(/(?<=\$)\d+(?:\.\d{2})?/g)].map((match) => match[0]);
console.log(prices.join(", "));Only the two numbers after a $ come back: 3.50, 12. The tax number 0.90 is skipped.
Which kind of check tests several rules before the last part of the regex uses up the text?
const strong = /^(?=.{8,}$)(?=.*[A-Z])(?=.*\d)(?!.*\s).+$/;
console.log(strong.test("Desk-2026"));The answer is lookahead. Positive lookaheads check the length, a capital letter, and a digit. A negative lookahead rejects spaces. All of this happens before .+ uses up the text.
Check your understanding
7 QUESTIONSQuestion 1 of 7How are anchors and lookarounds different from a regex part like
\d?Choose an answer to see the explanation.
Question 2 of 7What does JavaScript print when the text ends with a line break?
Read the code, then predictconsole.log(/end$/.test("end\n"));Choose an answer to see the explanation.
Question 3 of 7What does this print with the
mflag?Read the code, then predictconsole.log(/^beta$/m.test("alpha\nbeta\ngamma"));Choose an answer to see the explanation.
Question 4 of 7What does this word-edge check print?
Read the code, then predictconsole.log(/\bcafé\b/u.test("café"));Choose an answer to see the explanation.
Question 5 of 7What does the comma pattern print?
Read the code, then predictconsole.log("1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ","));Choose an answer to see the explanation.
Question 6 of 7What does the price lookbehind give back?
Read the code, then predictconst text = "Tea $3.50, mug $12"; console.log([...text.matchAll(/(?<=\$)\d+(?:\.\d{2})?/g)].map((m) => m[0]).join(", "));Choose an answer to see the explanation.
Question 7 of 7Which sentence about inline modifiers is true today?
Choose an answer to see the explanation.
Key takeaways
- Anchors, word boundaries, and lookarounds are zero-width assertions. They check a gap and use up no text.
- In JavaScript,
$withoutmmatches only the very end, not the gap before a last line break. \bonly knows A to Z, digits, and_. For words in any language, use lookarounds with\p{L}.- Lookaheads and lookbehinds use up no text. Round brackets inside a positive lookaround can still save text.
- Inline modifiers are new in ES2025. Test for support first, because Node 22 and Safari can't read them yet.
Remember the one-liner.
A position check lets a regex match the gap where something is true, not just the characters you get back.
Up next: Regex performance & safety. It explains backtracking, ReDoS attacks, and the sticky y flag, and why some patterns are safer than others.