Capturing groups

Wrap any slice of a pattern in parentheses pattern:(...) and you get a capturing group. Those parentheses are two tools in one.

  1. They pull a piece of the match out on its own, so you can read it separately from the whole match.
  2. They bind a quantifier to everything inside, so the quantifier repeats the group rather than the last character.

That second point is the one beginners trip over, so keep it in mind as we go: a quantifier grabs whatever sits directly to its left, and parentheses change what “the thing to its left” is.

Examples

The fastest way to feel how parentheses behave is to watch a quantifier land on one versus a single character.

Example: yoyoyo

Without parentheses, pattern:yo+ reads as “one subject:y, then subject:o repeated one or more times”. The pattern:+ only sees the subject:o next to it. So it matches match:yoooo or match:yooooooooo, but never a repeated yo.

Put yo in parentheses and the pattern:+ now repeats the whole pair. pattern:(yo)+ matches match:yo, match:yoyo, match:yoyoyo, and so on.

alert( 'Yoyoyo team!'.match(/(yo)+/ig) ); // "Yoyoyo"
yo+
y then o repeated
“yoooo”
(yo)+
yo as a unit, repeated
“yoyoyo”
The quantifier attaches to whatever is immediately left of it — a single char, or a whole group.

Example: domain

Now something with more moving parts: a pattern that finds a website domain.

shop.io
store.shop.io
eu.store.shop.io

Look at the shape. A domain is a run of words, each one followed by a dot, except the last word has no trailing dot. That “word-then-dot, repeated” is exactly a job for a group with a quantifier.

The pattern is pattern:(\w+\.)+\w+:

let regexp = /(\w+\.)+\w+/g;

alert( "blog.dev my.blog.dev".match(regexp) ); // blog.dev,my.blog.dev

The group pattern:(\w+\.) matches one word plus its dot, pattern:+ repeats that for every labeled segment, and the trailing pattern:\w+ catches the final piece that has no dot after it.

It works, but there’s a gap: it can’t match a hyphenated domain like my-blog.dev, because a hyphen isn’t part of the pattern:\w class (which is letters, digits, and underscore only). Widen the character class for every segment except the last: pattern:([\w-]+\.)+\w+.

Example: email

Extend that domain pattern one step and you have a passable email matcher.

An email is name@domain. The name part can hold letters, digits, dots, and hyphens, which is pattern:[-.\w]+. Glue the name, an @, and the domain pattern together:

let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g;

alert("me@shop.io @ her@blog.co.in".match(regexp)); // me@shop.io, her@blog.co.in

This is good enough to catch typos and pull addresses out of text. It is not a full RFC-compliant validator, and no regexp really is. The only test that proves an address is real is sending mail to it and seeing if it arrives.

Try that email matcher on your own text. Edit the pattern or the input and re-run to see which addresses the group-based domain rule catches.

interactivePull email addresses out of text

Parentheses contents in the match

Groups are numbered left to right by their opening parenthesis. As the engine matches, it remembers the text each group captured and hands it back to you.

str.match(regexp) without the pattern:g flag returns the first match as an array laid out like this:

  • Index 0: the entire match.
  • Index 1: what the first group captured.
  • Index 2: what the second group captured.
  • …and so on.

Say you want HTML tags pattern:<.*?> and you’d like the tag’s inner text on its own. Wrap that inner part in parentheses: pattern:<(.*?)>. Now the result holds both the full tag and its contents:

let str = '<p>Welcome aboard!</p>';

let tag = str.match(/<(.*?)>/);

alert( tag[0] ); // <p>
alert( tag[1] ); // p
tag[0]“<p>”full match
tag[1]“p”group 1 = (.*?)
match() without the g flag: index 0 is the whole match, then one slot per group.

Nested groups

Parentheses can sit inside other parentheses. The numbering rule stays the same: count opening parentheses from left to right.

Take the tag subject:<button type="submit">. You might want three things out of it:

  1. Everything inside the angle brackets: match:button type="submit".
  2. Just the tag name: match:button.
  3. Just the attributes: match:type="submit".

Add a group around each: pattern:<(([a-z]+)\s*([^>]*))>. The outer group wraps the whole content, and two inner groups split it into name and attributes.

Number them by the position of each opening paren, scanning left to right:

<1

2 [a-z]+

\s*

3 [^>]*

>
Group numbers follow the opening parentheses, left to right — outer paren first, then the inner ones.

In action:

let str = '<button type="submit">';

let regexp = /<(([a-z]+)\s*([^>]*))>/;

let result = str.match(regexp);
alert(result[0]); // <button type="submit">
alert(result[1]); // button type="submit"
alert(result[2]); // button
alert(result[3]); // type="submit"

Index 0 is always the full match. After that come the groups in opening-paren order:

  • result[1] is the outer group pattern:(([a-z]+)\s*([^>]*)) — the whole tag content.
  • result[2] is the next opening paren pattern:([a-z]+) — the tag name.
  • result[3] is pattern:([^>]*) — the attributes.

Here’s how each group maps onto the string:

result[1]button type=“submit”
result[2]button
result[3]type=“submit”
Each group's captured slice of the button tag.

Type a different tag below and watch the three numbered slots refill. The outer group holds the whole content, group 2 grabs the name, group 3 the attributes.

interactiveSplit a tag into its numbered groups

Optional groups

A group that’s marked optional with pattern:(...)? still keeps its slot in the result array even when it matched nothing. Its value is just undefined. The array length never depends on which optional groups happened to fire — it’s fixed by how many groups the pattern declares.

Take pattern:b(o)?(x)?: a "b", then maybe an "o", then maybe an "x". Run it against the bare string subject:b:

let match = 'b'.match(/b(o)?(x)?/);

alert( match.length ); // 3
alert( match[0] ); // b (whole match)
alert( match[1] ); // undefined
alert( match[2] ); // undefined

Length is 3 — index 0 plus two groups — and both groups came up empty.

Now the string subject:bx:

let match = 'bx'.match(/b(o)?(x)?/)

alert( match.length ); // 3
alert( match[0] ); // bx (whole match)
alert( match[1] ); // undefined, because there's nothing for (o)?
alert( match[2] ); // x

Same length, 3. The pattern:(o)? group found nothing so its slot is undefined, giving ["bx", undefined, "x"].

Searching for all matches with groups: matchAll

There’s a catch with match and the pattern:g flag: when you ask for all matches, it drops the group details entirely.

let str = '<p> <a>';

let tags = str.match(/<(.*?)>/g);

alert( tags ); // <p>,<a>

You get the list of full matches, but nothing about what each one’s groups captured. In real code you almost always want those group contents.

The method built for this is str.matchAll(regexp). It arrived long after match as the more capable successor. It also looks for matches, but it differs in three ways:

  1. It returns an iterable object, not a plain array.
  2. With the pattern:g flag, every match comes back as a full array including its groups.
  3. When nothing matches, it returns an empty iterable rather than null.

Watch the first difference bite:

let results = '<p> <a>'.matchAll(/<(.*?)>/gi);

// results is not an array, but an iterable object
alert(results); // [object RegExp String Iterator]

alert(results[0]); // undefined (*)

results = Array.from(results); // turn it into a real array

alert(results[0]); // <p>,p (1st tag)
alert(results[1]); // <a>,a (2nd tag)

Line (*) shows why the iterable trips people up: results[0] is undefined because you can’t index an iterator by number. Array.from materializes it into a proper array first. There’s more on iterables and array-likes in Iterables.

You often don’t need Array.from at all. A for..of loop consumes the iterable directly:

let results = '<p> <a>'.matchAll(/<(.*?)>/gi);

for(let result of results) {
  alert(result);
  // first alert: <p>,p
  // second: <a>,a
}

Destructuring works too, since it also iterates:

let [tag1, tag2] = '<p> <a>'.matchAll(/<(.*?)>/gi);

Each match from matchAll has the same shape as a groups-included match result: an array with extra index (where it starts in the string) and input (the original string) properties.

let results = '<p> <a>'.matchAll(/<(.*?)>/gi);

let [tag1, tag2] = results;

alert( tag1[0] ); // <p>
alert( tag1[1] ); // p
alert( tag1.index ); // 0
alert( tag1.input ); // <p> <a>
match(/<(.*?)>/g)
[ “<p>”, “<a>” ]
no group data
matchAll(/<(.*?)>/g)
[ “<p>”, “p” ]
[ “<a>”, “a” ]
groups kept
match(/…/g) flattens to full matches only; matchAll(/…/g) yields one full array — groups and all — per match.

Named groups

Counting parentheses to remember which group is which gets old fast, and it breaks the moment you edit the pattern and shift the numbers. Names fix that. Put pattern:?<name> right after a group’s opening paren and you can read it by name later.

Here’s a date matcher in “year-month-day” form:

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;
let str = "2024-08-15";

let groups = str.match(dateRegexp).groups;

alert(groups.year); // 2024
alert(groups.month); // 08
alert(groups.day); // 15

Named captures show up on the match’s .groups property, keyed by name.

(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})
match.groups →
year: “2024”month: “08”day: “15”
Named groups appear on match.groups, keyed by the names you chose.

To find every date, add the pattern:g flag. And since you want the group contents for each hit, reach for matchAll:

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2024-08-15 2025-02-09";

let results = str.matchAll(dateRegexp);

for(let result of results) {
  let {year, month, day} = result.groups;

  alert(`${day}.${month}.${year}`);
  // first alert: 15.08.2024
  // second: 09.02.2025
}

Destructuring result.groups by name reads far cleaner than juggling result[1], result[2], result[3], and it won’t silently break if you insert another group earlier in the pattern.

Enter a few “year-month-day” dates below. Each one that matches is read by name off result.groups and reprinted day-first.

interactiveRead dates by name with matchAll

Capturing groups in replacement

str.replace(regexp, replacement) can drop captured text into the replacement string. Reference a group by number with pattern:$n, where pattern:n is the group number.

Swap a name around:

let str = "Maya Ford";
let regexp = /(\w+) (\w+)/;

alert( str.replace(regexp, '$2, $1') ); // Ford, Maya

pattern:$1 is the first captured word, pattern:$2 the second, and the literal ", " between them stays as written.

Change the name below or the replacement template and re-run. Swap the $1 and $2 order, or drop in different literal text between them.

interactiveReorder captured words in a replacement

Named groups get referenced with pattern:$<name>. Reformat dates from “year-month-day” to “day.month.year”:

let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2024-08-15, 2025-02-09";

alert( str.replace(regexp, '$<day>.$<month>.$<year>') );
// 15.08.2024, 09.02.2025

Non-capturing groups with ?:

Sometimes you only want the grouping — to attach a quantifier or set up alternation — and you don’t want that text cluttering your results array. Mark a group as non-capturing by starting it with pattern:?:.

So pattern:(?:yo)+ still repeats yo as a unit, but yo never appears as its own array item. Here only the name match:Maya lands in a numbered slot:

let str = "Yoyoyo Maya!";

// ?: excludes 'yo' from capturing
let regexp = /(?:yo)+ (\w+)/i;

let result = str.match(regexp);

alert( result[0] ); // Yoyoyo Maya (full match)
alert( result[1] ); // Maya
alert( result.length ); // 2 (no more items in the array)
(yo)+ (\w+)
result[1] = “yo”
result[2] = “Maya”
length 3
(?:yo)+ (\w+)
result[1] = “Maya”
length 2
?: keeps the grouping behavior but skips the numbered slot, so downstream indices stay clean.

Summary

Parentheses do two jobs at once. They bundle part of a pattern so a quantifier applies to the whole bundle, and they capture the matched text for later use.

  • Groups are numbered left to right by their opening parenthesis, and nested groups follow the same left-to-right count.
  • You can name a group with pattern:(?<name>...) and read it off match.groups.

Getting captured text out depends on the method:

  • str.match returns capturing groups only without the pattern:g flag. With pattern:g you get full matches and no group detail.
  • str.matchAll always returns capturing groups, one full array per match, as a lazy iterable.

Unnamed groups are available by number in the match array; named groups also live on the groups property. Optional groups keep their slot as undefined when they match nothing, so the array length is constant.

In replacements, pull group content into the result with pattern:$n by number or pattern:$<name> by name.

Prefix a group with pattern:?: to make it non-capturing: you keep the grouping for quantifiers and alternation, but it takes no slot in the results and can’t be referenced from a replacement string.