All posts

How to test regex capture groups

How numbered, named and non-capturing regex groups work, why some come back undefined, and how to use them in replacements, with a worked log-line example.

By Last reviewed 6 min readRegexJavaScriptCapture groups

Most regex bugs aren't about whether the pattern matches. They're about what each group captures: the timestamp lands in the wrong group, an optional part comes back undefined, or a group you added for alternation quietly shifts every number after it. You find those by looking at the groups, not just at the highlighted match.

This guide covers how JavaScript numbers and names groups, with outputs you can reproduce. Paste each pattern into the regex tester as you read: its Match details panel lists every match with its index range, each numbered group and each named group, and prints undefined for a group that didn't take part.

Numbered groups

Parentheses do two jobs. They group part of a pattern so a quantifier or | applies to it, and they capture the text that part matched. Captures are numbered from 1; index 0 is the whole match.

const m = "2026-10-25".match(/(\d{4})-(\d{2})-(\d{2})/);
m[0]  // "2026-10-25"
m[1]  // "2026"
m[2]  // "10"
m[3]  // "25"

How nested groups are numbered

Groups are numbered by the position of their opening parenthesis, counting from the left. Nesting doesn't change the rule, so an outer group gets a lower number than the groups inside it:

((\d{4})-(\d{2}))-(\d{2})    on "2026-10-25"

Group 1   2026-10    the outer group opens first
Group 2   2026
Group 3   10
Group 4   25

Named groups are numbered too. (?<year>\d{4}) is both groups.year and a numbered group, so inserting a named group in the middle of a pattern renumbers everything after it, exactly like an unnamed one would. Only non-capturing groups and lookarounds such as (?=…) go without a number.

Named and non-capturing groups

Use a named group, (?<name>…), whenever code reads the capture. m.groups.status keeps working when someone adds a group earlier in the pattern; m[5] silently starts returning something else.

Use a non-capturing group, (?:…), when you need grouping but not the text. The classic case is alternation: (GET|POST) (\S+) has two groups and the path is m[2], while (?:GET|POST) (\S+) has one and the path is m[1]. The tester's status bar shows the capture group count for the current pattern, which is a quick way to catch a group you added by accident.

Worked example: fields from log lines

Here are three lines from an application log:

2026-10-25T14:03:07Z INFO  [api] GET /orders/1842 200 38ms
2026-10-25T14:03:09Z ERROR [payments] POST /charges 502 1204ms
2026-10-25T14:03:11Z WARN  [api] GET /orders/1843 404 12ms

You want the timestamp, level, service, path and status code. This pattern mixes numbered and named groups on purpose, so you can see that they share one numbering:

^(\S+) (?<level>[A-Z]+)\s+\[(?<service>[\w-]+)\] (?:GET|POST) (\S+) (?<status>\d{3})

The square brackets around the service are escaped as \[ and \], and the level is followed by \s+ because INFO and WARN are padded with two spaces. With the g and m flags on, the tester reports 3 matches and 5 capture groups. The first match reads:

Match 1 at index 0–53
2026-10-25T14:03:07Z INFO  [api] GET /orders/1842 200

Group 1   2026-10-25T14:03:07Z
Group 2   INFO
Group 3   api
Group 4   /orders/1842
Group 5   200
level     INFO
service   api
status    200

Three things to notice. (?:GET|POST) has no number, so the path is group 4. Each named group appears twice, once by number and once by name. And the match ends after the status code: the duration isn't in the pattern, so it isn't in the match.

Now switch off m. You get one match instead of three, because without multiline mode ^ only matches at the start of the whole input. Switch m back on and turn off g instead: again one match, because without the global flag the engine stops at the first.

In code, matchAll gives you every line's fields:

const re = /^(\S+) (?<level>[A-Z]+)\s+\[(?<service>[\w-]+)\] (?:GET|POST) (\S+) (?<status>\d{3})/gm;

const failures = [...log.matchAll(re)]
  .filter((m) => m.groups.level === "ERROR")
  .map((m) => m.groups.service + " " + m[4] + " " + m.groups.status);

// ["payments /charges 502"]

In production code, name the path and timestamp as well. They're left numbered here only to show how the numbering works.

Three results that look like bugs

  • A repeated group keeps only its last iteration. (\d)+ on 123 matches 123, but group 1 is 3. Put the quantifier inside the group, (\d+), to capture all of it.
  • An optional group that didn't match is undefined, not an empty string. (\d+)(?:\.(\d+))? with g on 42 3.14 gives 42 with group 2 undefined, then 3.14 with groups 3 and 14. Check for undefined before calling string methods on a capture.
  • Groups in an alternative that wasn't taken are undefined too. (\d+)|id=(\d+) on id=7 fills group 2 and leaves group 1 undefined, because the second alternative is the one that matched.

Backreferences inside the pattern

A backreference matches the same text a group already captured. \1 refers to group 1 and \k<name> to a named group.

\b(\w+)\s+\1\b       flags gi, on "The the logs. This is is fine."
  match 1  "The the"  at index 0, group 1 = The
  match 2  "is is"    at index 19, group 1 = is

(?<q>["']).*?\k<q>     flags g, on: name="ada" note='it"s'
  match 1  "ada"     q = "
  match 2  'it"s'    q = '

The first finds doubled words; the i flag makes the backreference case-insensitive too, which is why The the counts. The second is the more useful trick: the closing quote must be the same character as the opening one, so the double quote inside 'it"s' doesn't end the match. A character class alone can't express that.

Groups in replacements

In String.prototype.replace, $1 inserts group 1, $<name> inserts a named group and $& inserts the whole match:

"2026-10-25".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1")
// "25/10/2026"

"2026-10-25".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>")
// "25/10/2026"

"total 40 USD, fee 3 USD".replace(/(\d+) USD/g, (match, amount) => "$" + (amount * 1.1).toFixed(2))
// "total $44.00, fee $3.30"

When the replacement needs logic, pass a function. It receives the whole match, then each group in order, then the offset and the input string, and finally the named groups object if the pattern has named groups. One gotcha with the string form: if the pattern has fewer than ten groups, $10 means group 1 followed by a literal 0.

The tester shows matches, not replacement output, so the workflow is: get every group right in the tester first, and the replacement string becomes mechanical.

The g flag, match() and matchAll()

  • str.match(re) without g returns the first match with its numbered captures, its index and the groups object.
  • str.match(re) with g returns only the full matches. The groups are gone.
  • str.matchAll(re) returns every match with its groups. It requires the g flag and throws a TypeError without it.
  • re.exec(str) with g returns one match per call and moves re.lastIndex forward. That's the loop matchAll runs for you.

That last point causes a classic bug. A global regex remembers where it stopped, even across calls to test(): with const r = /ERROR/g, calling r.test(log) twice on the same string returns true and then false. Drop the g when you only need a yes or no.

Next steps

Open the regex tester, paste a few lines of your own logs and build the pattern one group at a time, checking Match details after each change. For syntax you don't use daily, the regex cheat sheet has a Try it link on every row, and the guide to regex differences between JavaScript, Python and Java covers what changes when the pattern moves to the backend.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.