Regex Alternation and Groups: Using | With (?:…), Anchors and Captures

The pipe `|` is the regex "or": `cat|dog` matches either word. On its own it is simple, but it has the lowest precedence of any regex operator, so it splits everything around it unless a group limits its reach. Most alternation bugs come from that: anchors that apply to only one branch, captures that come back undefined, and branches tried in an order you did not expect. Each example below shows real output from JavaScript, with Python where its behaviour differs.

How alternation works: leftmost branch wins

A regex engine of the kind used by JavaScript, Python, PHP, Java and .NET tries the alternatives from left to right at each position and takes the first one that leads to an overall match. It does not look for the longest alternative:

"category".match(/cat|category/)[0];    // 'cat'
"category".match(/category|cat/)[0];    // 'category'

This is the main rule to remember. When one alternative is a prefix of another, put the longer one first, or make the match end at a boundary so the shorter one cannot succeed early: \b(?:cat|category)\b finds whole words regardless of order. POSIX tools such as grep -E follow a different, leftmost-longest rule, so the same pattern can behave differently there.

The anchor trap: ^a|b$

Because | binds more loosely than anything else, ^a|b$ means "a at the start" or "b at the end", not "exactly a or exactly b":

/^a|b$/.test("ax");        // true  — starts with a
/^a|b$/.test("xb");        // true  — ends with b

/^(?:a|b)$/.test("ab");    // false
/^(?:a|b)$/.test("a");     // true  — the whole string is a or b

This mistake is common in validation code, such as ^png|jpg|gif$ for a file type, which accepts any string that starts with png, contains jpg anywhere, or ends with gif. Wrap the alternatives in a group whenever anything else, anchors included, belongs in the pattern:

["a.png", "b.jpeg", "c.jpg", "d.gif", "e.webp", "f.png.exe"]
  .filter(f => /\.(?:png|jpe?g|gif)$/i.test(f));
// [ 'a.png', 'b.jpeg', 'c.jpg', 'd.gif' ]

["a.png.exe"].filter(f => /\.png|\.jpg/.test(f));
// [ 'a.png.exe' ]  — no group, no anchor: dangerous for uploads

Capturing versus non-capturing groups

Plain parentheses (…) both group and capture: the text matched by the group is saved and returned. (?:…) only groups. With alternation the difference matters for performance, readability and, in some APIs, for what you get back:

[..."grey gray".matchAll(/gr(a|e)y/g)].map(m => m[1]);   // [ 'e', 'a' ]  captured letter
"grey gray".match(/gr(?:a|e)y/g);                        // [ 'grey', 'gray' ]

# Python: findall returns the groups when a pattern has them
re.findall(r"gr(a|e)y", "grey gray")      # ['e', 'a']
re.findall(r"gr(?:a|e)y", "grey gray")    # ['grey', 'gray']

Python's re.findall is the classic surprise: adding parentheses for grouping changes the result from full matches to captured groups. Use (?:…) by default and capture only what you need to extract. For single characters, a character class is simpler still: gr[ae]y is equivalent to gr(?:a|e)y and faster.

Captures inside alternatives are often undefined

When each branch has its own capturing group, only the group in the branch that matched gets a value; the others are undefined in JavaScript and None in Python:

const size = /(?:(\d+)px|(\d+)em)/;
"12em".match(size).slice(1);    // [ undefined, '12' ]
"12px".match(size).slice(1);    // [ '12', undefined ]

Code that always reads group 1 breaks on the second branch. Either restructure so the shared part is captured once, (\d+)(?:px|em), or use named groups and check which one is set. Named groups ((?<name>…) in JavaScript, .NET and PCRE, (?P<name>…) in Python) also make the code readable:

"2026-10-19".match(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/).groups;
// { y: '2026', m: '10', d: '19' }

re.match(r"(?P<y>\d{4})-(?P<m>\d{2})", "2026-10").groupdict()
# {'y': '2026', 'm': '10'}

Some flavours, including .NET and PCRE, allow the same name in several branches; JavaScript added it in ES2025 (it works in Node.js 24), so do not rely on duplicate names in code that must run in older browsers.

Factoring alternatives

Long lists of alternatives that share text can be factored, which makes them shorter and gives the engine less to try. \b(?:Mon|Tues|Wednes)day\b matches the same days as \b(?:Monday|Tuesday|Wednesday)\b. Optional parts are often clearer with ? than with an empty alternative: colou?r instead of col(?:o|ou)r.

Do not factor so aggressively that the pattern becomes unreadable, though. A list of literal words is easy to review; a deeply nested factored version is not, and the speed difference is negligible for short inputs. Build long word lists programmatically, escaping each word, and sort them longest first so that the leftmost-wins rule cannot pick a prefix.

Alternation and catastrophic backtracking

Alternatives that can match the same text, repeated with + or *, give the engine an exponential number of ways to split the input. When the overall match fails, it tries them all. ^(a|aa)+$ against a run of a followed by b is the textbook case:

/^(a|aa)+$/.test("a".repeat(36) + "b");   // false — after about 0.5 s in our test
/^a+$/.test("a".repeat(36) + "b");        // false — instantly

On our machine each extra a made the overlapping version about 1.6 times slower, so a 50-character input would take minutes. That is a denial-of-service risk in any server that applies such a pattern to user input, and the cause is common in practice: (\w|\s)+, (.|\n)* and (\d+|\d+\.\d+)* all overlap. Make the alternatives mutually exclusive or replace them with a character class ([\w\s]+, [\s\S]*), and test edge cases with long non-matching input in a regex tester before deploying. The common regex mistakes page covers more patterns of this kind.

Sources and further reading

The ECMAScript specification defines the Disjunction grammar and its left-to-right semantics; MDN's "Disjunction", "Capturing group", "Non-capturing group" and "Named capturing group" pages document them for JavaScript. The Python re documentation covers |, (?:…), (?P<name>…) and the behaviour of findall with groups. POSIX (IEEE Std 1003.1) specifies the leftmost-longest rule for extended regular expressions. OWASP's article on Regular expression Denial of Service (ReDoS) explains catastrophic backtracking.

Frequently asked questions

How does the | operator work in regex?

It separates alternatives, and the engine tries them left to right at each position, taking the first one that leads to a match. It has the lowest precedence, so it splits the whole pattern unless a group limits it.

Why does my regex match the shorter alternative?

Backtracking engines take the leftmost alternative that works, not the longest. Put longer alternatives first, as in category|cat, or add a word boundary after the group.

Why does ^a|b$ match too much?

It means a at the start or b at the end, because | binds more loosely than the anchors. Write ^(?:a|b)$ to require that the whole string is one of the alternatives.

What is the difference between (a|b) and (?:a|b)?

Both group the alternatives, but (a|b) also captures the matched text as a numbered group, while (?:a|b) does not. Use the non-capturing form unless you need the value.

Why is my capture group undefined?

The group sits in an alternative that did not match, so it has no value. Capture the shared part once outside the alternation, or use named groups and check which one is set.

Can alternation make a regex slow?

Yes. Overlapping alternatives inside a repetition, such as (a|aa)+ or (\w|\s)+, can cause exponential backtracking on non-matching input. Use mutually exclusive alternatives or a character class instead.