Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Regular Expressions  Module 6 – Lookaround and advanced constructs

Conditionals, recursion and PCRE2 extras

How conditional patterns, recursion, branch reset and backtracking-control verbs work in PCRE2, and why CPython, JavaScript and Go refuse most of them.

  • Advanced
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Go 1.27.1, PHP 8.5.11 and Java temurin-25.0.4+101.0.LTS
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Read and write conditional patterns that branch on whether a group matched
  • Match balanced parentheses with recursion and explain which flavours accept it
  • Use branch reset and backtracking-control verbs in PCRE2 patterns
  • Decide when a pattern should become a small parser instead

Before you start

What you’ll learn · Before you start

What you will learn

  • Read and write conditional patterns that branch on whether a group matched
  • Match balanced parentheses with recursion and explain which flavours accept it
  • Use branch reset and backtracking-control verbs in PCRE2 patterns
  • Decide when a pattern should become a small parser instead

Before you start

On this page

One pattern, \((?:[^()]++|(?R))*\), finds the balanced (a(b)c) in PCRE2 10.49 and in PHP 8.5.11, and four other engines refuse to compile it. CPython stops at (?R) with unknown extension ?R, Java 25 stops at the same place with Unknown inline modifier, and JavaScript and Go fail even earlier, at the possessive ++, which neither of them has.

The constructs behind that split are the subject here: conditional patterns that branch on whether a group matched, recursion for nested brackets, branch reset, backtracking-control verbs and the items that can open a PCRE2 pattern. Every verdict on this page comes from a recorded run, and the last section weighs them against a short parser that does the bracket job in any language.

A conditional branches on whether a group matched

A conditional pattern: A group written (?(condition)yes|no) that matches the yes part when the condition holds, for example when group 1 took part in the match, and the no part otherwise. has the form (?(cond)yes|no). When the condition is a group number, the engine asks one question at that point: did that group take part in the match so far? If it did, the yes part must match next; if not, the no part, which may be left out. The pattern ^(<)?\w+(?(1)>)$ uses this to accept a bare word such as word or a word wrapped in angle brackets such as <tag>, but not a half-wrapped <broken:

Python: a conditional for optional brackets Python · conditional/conditional.py
"""Conditional pattern in CPython: accepts '<tag>' and 'word', rejects '<broken'."""
import re

pattern = r"^(<)?\w+(?(1)>)$"
rx = re.compile(pattern)

subjects = ["<tag>", "word", "<broken"]
print(f"Pattern: {pattern}")
print("Subjects and verdicts:")
for s in subjects:
    verdict = "match" if rx.match(s) else "no match"
    print(f"  {s!r:15} -> {verdict}")

Output

Pattern: ^(<)?\w+(?(1)>)$
Subjects and verdicts:
  '<tag>'         -> match
  'word'          -> match
  '<broken'       -> no match

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 conditional.py

Group 1 captures the opening < when there is one. The condition (?(1)>) then says: if group 1 took part, a > must come next. So <tag> matches (group 1 holds < and the closing > is there), word matches (group 1 did not take part, so nothing more is required), and <broken fails (group 1 holds <, but no > follows the word). PCRE2 gives the same three verdicts, and Go refuses the pattern:

Conditionals in PCRE2 and Go

PCRE2 10.49 · conditional/conditional_pcre2.py

"""Conditional pattern in PCRE2 10.49."""
from pcre2_session import run

run(r'''
# Conditional: if group 1 matched, require >
/^(<)?\w+(?(1)>)$/
    <tag>
    word
    <broken
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# Conditional: if group 1 matched, require >
/^(<)?\w+(?(1)>)$/
    <tag>
 0: <tag>
 1: <
    word
 0: word
    <broken
No match

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 conditional_pcre2.py

conditional/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

Go 1.27.1 · conditional/conditional.go

// Go's regexp package rejects conditional patterns
package main

import (
	"fmt"
	"regexp"
)

func main() {
	pattern := `^(<)?\w+(?(1)>)$`
	_, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Go regexp rejects this pattern:")
		fmt.Println(err.Error())
	}
}

Output

Go regexp rejects this pattern:
error parsing regexp: invalid or unsupported Perl syntax: `(?(`

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run conditional.go

Go’s message names the spot where its parser stopped: invalid or unsupported Perl syntax, followed by (?(. The RE2 syntax page lists conditional branches as not supported. JavaScript refuses the pattern too:

JavaScript refuses conditional patterns JavaScript · conditional-js/conditional.mjs
// JavaScript refuses conditional patterns: try to compile ^(<)?\w+(?(1)>)$
// This pattern accepts '<tag>' and 'word' but not '<broken' in PCRE2 and Python.
try {
  const rx = new RegExp("^(<)?\\w+(?(1)>)$");
  console.log("Pattern compiled:", rx);
} catch (e) {
  console.log("JavaScript raises", e.name);
  console.log("Message:", e.message);
}

Output

JavaScript raises SyntaxError
Message: Invalid regular expression: /^(<)?\w+(?(1)>)$/: Invalid group

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node conditional.mjs

Version note

Clicking Run compiles the pattern with QuickJS 0.32.0 instead of V8 on Node.js. QuickJS raises the same SyntaxError with the shorter message invalid group; both engines refuse (?( as unknown syntax.

In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:

JavaScript raises SyntaxError
Message: invalid group

V8, the engine of Node.js and Chrome, says Invalid group, because (?( does not start any group the ECMAScript grammar defines. Java 25 refuses the pattern as well, as the comparison in the last section records.

Key idea

A conditional asks whether a group took part in the match so far and picks its yes or its no part. CPython and PCRE2 run it; JavaScript, Go and Java 25 refuse it, so portable code tests the group after a plain match instead.

Checkpoint

Check: Conditional patterns

  1. Question 1 of 2 Which pattern requires a closing > only when group 1 matched an opening <?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ^(<)?\w+(?(1)>)$

    A conditional (?(1)yes) checks whether group 1 took part in the match so far. Only the first pattern makes the closing > depend on the opening <.

  2. Question 2 of 2 Which engine of this lesson compiles ^(<)?\w+(?(1)>)$ and accepts both <tag> and word?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: CPython 3.14.8

    Among the engines run in this lesson, conditionals compile in CPython and PCRE2. JavaScript, Go and Java 25 refuse the pattern before they read a subject.

Recursion matches nested brackets

A regular expression in the strict sense of language theory cannot check that brackets balance, because that needs a count with no upper bound. PCRE2 goes past that limit with recursion: A call written (?R) or (?0) that runs the whole pattern again at the current position, inside the match in progress, so a pattern can match nested structures such as balanced brackets.: (?R) runs the whole pattern again at the current position, inside the match in progress. The pattern \((?:[^()]++|(?R))*\) reads as an opening bracket, then any number of runs of non-brackets or nested balanced groups, then a closing bracket:

Recursion in PCRE2 and PHP, rejection in CPython

PCRE2 10.49 · balanced/balanced_pcre2.py

"""Recursion in PCRE2 10.49: balanced parentheses."""
from pcre2_session import run

run(r'''
# Match balanced parentheses with recursion
/\((?:[^()]++|(?R))*\)/g
    (a(b)c) and (x)
    ((()(
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# Match balanced parentheses with recursion
/\((?:[^()]++|(?R))*\)/g
    (a(b)c) and (x)
 0: (a(b)c)
 0: (x)
    ((()(
 0: ()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 balanced_pcre2.py

balanced/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

PHP 8.5.11 · balanced/balanced.php

<?php
// PHP's PCRE2 accepts recursion
$pattern = '/\((?:[^()]++|(?R))*\)/';
$subject = '(a(b)c) and (x)';

preg_match_all($pattern, $subject, $matches);
echo "Pattern: $pattern\n";
echo "Subject: $subject\n";
echo "Matches: " . json_encode($matches[0]) . "\n";

Output

Pattern: /\((?:[^()]++|(?R))*\)/
Subject: (a(b)c) and (x)
Matches: ["(a(b)c)","(x)"]

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php balanced.php

Python 3.14.8 · balanced/balanced.py

"""CPython re rejects recursion: try to compile the balanced-parentheses pattern."""
import re

pattern = r"\((?:[^()]++|(?R))*\)"
try:
    rx = re.compile(pattern)
    print("Pattern compiled")
except re.error as e:
    print(f"CPython re raises re.error: {e}")

Output

CPython re raises re.error: unknown extension ?R at position 14

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 balanced.py

PCRE2 and PHP find (a(b)c) and (x) on the first line. PCRE2’s second subject, ((()(, holds only one balanced pair, the inner (), so that is its one match. CPython stops at compile time with unknown extension ?R. The third-party regex module for Python does support recursive patterns, as its README states, but it is a separate install, not the standard re. JavaScript refuses the pattern for two reasons, and the run shows them one at a time:

JavaScript refuses recursion JavaScript · balanced-js/balanced.mjs
// JavaScript has neither possessive quantifiers nor recursion. Compile the balanced-brackets pattern with ++, then
// the same pattern without it, and print what the engine says about each.
const forms = [
  ['with ++', String.raw`\((?:[^()]++|(?R))*\)`],
  ['without ++', String.raw`\((?:[^()]|(?R))*\)`],
];

for (const [label, source] of forms) {
  try {
    new RegExp(source);
    console.log(`${label}: compiled`);
  } catch (e) {
    console.log(`${label}: ${e.name}`);
    console.log(`  ${e.message}`);
  }
}

Output

with ++: SyntaxError
  Invalid regular expression: /\((?:[^()]++|(?R))*\)/: Nothing to repeat
without ++: SyntaxError
  Invalid regular expression: /\((?:[^()]|(?R))*\)/: Invalid group

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node balanced.mjs

Version note

The Run button runs this file in QuickJS rather than in Node.js. QuickJS words the two errors nothing to repeat and invalid group, and stops at the same two places as V8.

In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:

with ++: SyntaxError
  nothing to repeat
without ++: SyntaxError
  invalid group

With the ++ in place, V8 stops at the second plus with Nothing to repeat, because JavaScript has no possessive quantifier: A quantifier with an extra plus sign, such as x*+, x++, x?+ or x{m,n}+, that matches as much as a greedy one and then keeps it: it saves no shorter run, so a failure later in the pattern never makes it give characters back. More in “Possessive quantifiers and atomic groups”; without it, V8 reaches (?R and says Invalid group. Step through the recursive match on (a(b)c) in PCRE2:

Interactive · step by step How recursion matches balanced parentheses Step through \((?:[^()]++|(?R))*\) on the text (a(b)c), watching the depth increase
How recursion matches balanced parentheses, step 7 of 7. Showing Depth (0), (a(b)c) ((a(b)c) done).Depth0(a(b)c)(a(b)c) done

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Start at depth 0. The engine finds ( at position 0 and enters the outer group at depth 0. The possessive [^()]++ will take everything up to the next parenthesis.
  2. Match a, reach (. The [^()]++ matches the letter a. At position 2 the engine sees ( and cannot match it with [^()] or advance further without recursion.
  3. Recurse to depth 1. (?R) starts a recursive call. The engine re-enters the whole pattern at depth 1, looking to match the inner parentheses.
  4. Match b, reach ). Inside the recursive call, [^()]++ matches b. At position 4 the engine sees ) which ends this level.
  5. Return from depth 1. The ) closes the inner group. The recursive call succeeds with (b) and returns to depth 0.
  6. Match c, reach ). Back at depth 0, [^()]++ matches c. At position 6 the engine sees ) which ends the outer group.
  7. Outer group closes. The ) at position 6 closes the outer parentheses. The whole match is (a(b)c), all seven characters.

The depth rises when the engine enters (?R) for the inner (b) and falls when that call returns to finish the outer brackets. The ++ matters for speed as well as for portability: it makes each run of non-brackets one block that is never split, the same effect as the atomic group: A group written (?>...) that keeps the first match its own pattern finds: a failure later in the pattern cannot backtrack into it to make it try another way. x++ means the same as (?>x+). More in “Possessive quantifiers and atomic groups” (?>[^()]+). Write [^()]+ instead and a bracket that never closes turns expensive, because before giving up on that ( the engine tries every way of dividing the run of letters between the inner + and the outer *. pcre2test can count the work: its find_limits modifier reports the smallest match limit, the number of times the matcher calls itself, that a subject needs.

PCRE2: the cost of the plus sign, counted by find_limits

PCRE2 10.49 · cost/cost_pcre2.py

"""How much work the possessive ++ saves in the balanced-brackets pattern.

pcre2test's find_limits reports the smallest match limit each pattern needs on a subject, that is, how many times the
matcher had to call itself. The subject opens a bracket that never closes, so the first attempt has to fail before the
engine finds the (b) further on.
"""

from pcre2_session import run

run(r'''
# Possessive: the run of a characters is taken once and never split
/\((?:[^()]++|(?R))*\)/
    (aaaaaaaaaaaaaaaa(b)\=find_limits

# Greedy: every way of splitting the run is tried before the first ( gives up
/\((?:[^()]+|(?R))*\)/
    (aaaaaaaaaaaaaa(b)\=find_limits
    (aaaaaaaaaaaaaaaa(b)\=find_limits
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# Possessive: the run of a characters is taken once and never split
/\((?:[^()]++|(?R))*\)/
    (aaaaaaaaaaaaaaaa(b)\=find_limits
Minimum heap limit = 2
Minimum match limit = 22
Minimum depth limit = 12
 0: (b)

# Greedy: every way of splitting the run is tried before the first ( gives up
/\((?:[^()]+|(?R))*\)/
    (aaaaaaaaaaaaaa(b)\=find_limits
Minimum heap limit = 7
Minimum match limit = 188417
Minimum depth limit = 53
 0: (b)
    (aaaaaaaaaaaaaaaa(b)\=find_limits
Minimum heap limit = 7
Minimum match limit = 753665
Minimum depth limit = 59
 0: (b)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cost_pcre2.py

cost/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

The possessive form settles (aaaaaaaaaaaaaaaa(b) in 22 calls. The greedy form needs 188,417 calls with 14 letters and 753,665 with 16: two more letters, four times the work, so each extra letter doubles it. Both finally report the same match, (b), which is why the greedy form passes every test on short, balanced input and shows its cost only on long, broken text.

A greedy run inside the repeated group

Wrong: \((?:[^()]+|(?R))*\) on text you do not control, since an unclosed bracket costs it 753,665 calls on 16 letters. Right: \((?:[^()]++|(?R))*\), which reads each run of letters one way only and needs 22.

Recursion has a close relative, the subroutine call: A call such as (?1) or (?&name) that runs the pattern of one group again at the current position, as a program calls a function; groups it captures are put back to their old values when it returns.: (?1) runs the pattern of group 1 again at the current position. Groups captured inside a call are put back to their old values when it returns, as pcre2pattern states. Since PCRE2 10.47 a call may also list groups to hand back, written (?1(2)); the ChangeLog describes it as a subroutine call that also returns the listed capture groups to the caller:

PCRE2 10.49: a subroutine call that returns group 2

PCRE2 10.49 · returns/returns_pcre2.py

"""A subroutine call that hands a group back (PCRE2 10.47 and later; PHP 8.5.11 bundles 10.44 and lacks it).

(?1) runs the pattern of group 1 again at the current position. Captures it makes inside are undone when it returns,
so group 2 keeps ann. The 10.47 form (?1(2)) lists group 2 as a value to return, so group 2 ends up holding bob.
"""

from pcre2_session import run

run(r'''
# An ordinary subroutine call: captures made inside it revert when it returns
/((\w+)@\w+) (?1)/
    ann@x bob@y

# The 10.47 form: (?1(2)) returns group 2 to the caller
/((\w+)@\w+) (?1(2))/
    ann@x bob@y
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# An ordinary subroutine call: captures made inside it revert when it returns
/((\w+)@\w+) (?1)/
    ann@x bob@y
 0: ann@x bob@y
 1: ann@x
 2: ann

# The 10.47 form: (?1(2)) returns group 2 to the caller
/((\w+)@\w+) (?1(2))/
    ann@x bob@y
 0: ann@x bob@y
 1: ann@x
 2: bob

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 returns_pcre2.py

returns/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

After the plain (?1), group 2 still holds ann, from the first pass through group 1; after (?1(2)) it holds bob, the value the call captured. PHP 8.5.11 bundles PCRE2 10.44, older than 10.47, so this form is not available there yet, which is why the run above comes from the PCRE2 10.49 library itself.

Checkpoint

Check: Recursion and its cost

  1. Question 1 of 2 Which engines on this page compile \((?:[^()]++|(?R))*\)?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • PCRE2 10.49
    • PHP 8.5.11

    Only the PCRE2 family runs this pattern here. CPython fails at (?R), while JavaScript and Go already fail at the ++.

  2. Question 2 of 2 When \((?:[^()]++|(?R))*\) matches (a(b)c), how deep does the recursion go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: One level: (?R) is entered once, for the inner (b)

    The pattern enters (?R) each time it meets a nested opening bracket. (a(b)c) has one nested pair, so the recursion goes one level deep and comes back to finish the outer brackets.

Branch reset gives every alternative the same numbers

Capture groups are normally numbered left to right by their opening brackets, across every alternative. In (cat)|(dog) the group of the second alternative is group 2, so code that reads the animal has to look in two places. A branch reset group: A group written (?|...) in which every alternative numbers its capture groups from the same starting number, so one group number holds the result whichever alternative matched., written (?|...), starts the numbering again at each |, so (?|(cat)|(dog)) puts either animal in group 1. The run tries both patterns on cat and dog:

PCRE2: without and with branch reset

PCRE2 10.49 · branch-reset/branch_reset_pcre2.py

"""Branch reset in PCRE2 10.49: with (?|...) both alternatives use group 1.

The first pattern has no branch reset, so dog lands in group 2 and pcre2test shows group 1 as <unset>.
"""

from pcre2_session import run

run(r'''
# Without branch reset: each alternative has its own group number
/(cat)|(dog)/g
    cat and dog

# Branch reset: (?| ... | ... ) numbers both alternatives as group 1
/(?|(cat)|(dog))/g
    cat and dog
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# Without branch reset: each alternative has its own group number
/(cat)|(dog)/g
    cat and dog
 0: cat
 1: cat
 0: dog
 1: <unset>
 2: dog

# Branch reset: (?| ... | ... ) numbers both alternatives as group 1
/(?|(cat)|(dog))/g
    cat and dog
 0: cat
 1: cat
 0: dog
 1: dog

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 branch_reset_pcre2.py

branch-reset/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

Without branch reset, the match on dog shows group 1 as <unset> and the animal in group 2; with it, group 1 holds cat the first time and dog the second. pcre2pattern traces the feature to Perl 5.10, and among the engines run on this page only PCRE2, and PHP through it, accept it: CPython reports unknown extension ?|, Go and Java refuse it as well (their messages are in the last section), and the RE2 syntax page lists branch numbering reset as not supported. The portable form is the first pattern plus one line of code that reads whichever group is set.

Checkpoint

Check: Branch reset groups

  1. One question In (?|(cat)|(dog)), which group holds the match on dog?

    Choose one answer.

    Show the answer: One question

    Answer: Group 1

    Inside (?|...) every alternative starts counting its groups at the same number. The recorded run shows 1: dog with branch reset and 2: dog without it.

Backtracking-control verbs skip text without reporting it

PCRE2 has backtracking-control verb: An item written (*NAME), such as (*FAIL) or (*SKIP), that tells a backtracking engine what to do at that point: (*FAIL) fails at once, and (*SKIP) makes the next attempt start where it stood., items written (*NAME) that tell the engine what to do when it reaches them. Two of them work as a pair:

  • (*FAIL) fails at once, like a branch that can never match.
  • (*SKIP) acts when the rest of the pattern fails: the next attempt starts at the position where (*SKIP) stood, not one character after the old start, so the text matched before it can never be part of a match.

Together they let a pattern consume a stretch of text and throw it away. The pattern below finds the words that are not inside double quotes:

(*SKIP)(*FAIL) in PCRE2

PCRE2 10.49 · skip-fail/skip_fail_pcre2.py

"""The (*SKIP)(*FAIL) idiom: skip quoted strings, match words outside them."""
from pcre2_session import run

run(r'''
# Match words but skip anything inside double quotes
/"[^"]*"(*SKIP)(*FAIL)|\w+/g
    he said "ignore this" then yes
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# Match words but skip anything inside double quotes
/"[^"]*"(*SKIP)(*FAIL)|\w+/g
    he said "ignore this" then yes
 0: he
 0: said
 0: then
 0: yes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 skip_fail_pcre2.py

skip-fail/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

The first alternative, "[^"]*"(*SKIP)(*FAIL), matches the quoted string and then fails on purpose. Because of (*SKIP), the next attempt starts after the closing quote, so the second alternative, \w+, is never tried on ignore or this. Only he, said, then and yes are reported. Perl has the same verbs, as perlre documents; CPython, Go and Java reject the pattern at the * right after the opening bracket of (*SKIP). In those engines the job is done with an alternation and a capture group, as the lookahead lesson shows: match the quoted string as one alternative, capture the word in the other, and keep only the matches whose group is set.

Key idea

(*SKIP)(*FAIL) is PCRE2’s way of saying “consume this and never report it”. Engines without verbs reach the same result with an extra group and code that keeps only the matches that set it.

Checkpoint

Check: The skip and fail verbs

  1. One question Which words does "[^"]*"(*SKIP)(*FAIL)|\w+ report on he said "ignore this" then yes?

    Choose one answer.

    Show the answer: One question

    Answer: he, said, then and yes

    The first alternative consumes a quoted string and then fails; (*SKIP) makes the next attempt start after the quotes. The second alternative reports every word outside them.

Start-of-pattern items set options for the whole match

A PCRE2 pattern may begin with start-of-pattern item: An option written at the very start of a PCRE2 pattern, such as (*UCP) or (*LIMIT_MATCH=n), that changes how the whole pattern is compiled or matched., options in the form (*NAME) that apply to the whole match. Two are worth knowing. (*UCP) makes \d, \w and \s use Unicode properties, so \d matches any decimal digit: A character of Unicode general category Nd: a digit 0 to 9 of some script, such as the ASCII 7, the Devanagari ७ or the Tamil ௭. \p{Nd} matches all of them; [0-9] matches only the ASCII ten. More in “Shorthand classes for digits, words and spaces” rather than only the ten ASCII ones. (*LIMIT_MATCH=n) caps the number of match calls, the count that find_limits reported above, so a runaway match stops with an error. The run tries both, on the Devanagari number ४२ (forty-two) and on the subject that cost the greedy recursion 753,665 calls:

PCRE2: (*UCP) and (*LIMIT_MATCH=n)

PCRE2 10.49 · start-items/start_items_pcre2.py

"""Two start-of-pattern items of PCRE2.

(*UCP) makes \\d use the Unicode property Nd, so it matches every decimal digit, not only the ASCII ones.
(*LIMIT_MATCH=n) caps the number of match calls, so a runaway match stops with error -47 instead of running on.
pcre2test shows a character outside ASCII by its code point, as \\x{...}.
"""

from pcre2_session import run

run(r'''
# Without (*UCP), \d means the ten ASCII digits only
/^\d+$/utf
    42
    ४२

# With (*UCP), \d also matches the Devanagari digits four and two
/(*UCP)^\d+$/utf
    ४२

# A cap of 100000 match calls: the greedy form runs out, the possessive one does not
/(*LIMIT_MATCH=100000)\((?:[^()]+|(?R))*\)/
    (aaaaaaaaaaaaaaaa(b)

/(*LIMIT_MATCH=100000)\((?:[^()]++|(?R))*\)/
    (aaaaaaaaaaaaaaaa(b)
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# Without (*UCP), \d means the ten ASCII digits only
/^\d+$/utf
    42
 0: 42
    ४२
No match

# With (*UCP), \d also matches the Devanagari digits four and two
/(*UCP)^\d+$/utf
    ४२
 0: \x{96a}\x{968}

# A cap of 100000 match calls: the greedy form runs out, the possessive one does not
/(*LIMIT_MATCH=100000)\((?:[^()]+|(?R))*\)/
    (aaaaaaaaaaaaaaaa(b)
Failed: error -47: match limit exceeded

/(*LIMIT_MATCH=100000)\((?:[^()]++|(?R))*\)/
    (aaaaaaaaaaaaaaaa(b)
 0: (b)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 start_items_pcre2.py

start-items/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

Without (*UCP), ^\d+$ matches 42 and reports No match for ४२; with it, the Devanagari digits match, and pcre2test prints them by code point as \x{96a}\x{968}. Under a cap of 100,000 calls, the greedy pattern stops with error -47: match limit exceeded, while the possessive one, which needs only 22 calls, still finds (b). The cap is a match limit: A cap on the steps of the matching loop of PCRE2 that turns a runaway match into an error instead of an endless run. pcre2test reports error -47 when it is reached, and PHP returns false. More in “Regex in PCRE2, PHP and Perl” the pattern sets for itself, and pcre2pattern adds one rule: a pattern can lower the limit its caller set, but never raise it. That makes (*LIMIT_MATCH=n) a safety net a pattern author can add, not a way round a limit the program chose. Other engines set such options through their own interfaces, where they have them at all.

Checkpoint

Check: Start-of-pattern items

  1. Question 1 of 2 What does (*UCP) change in (*UCP)^\d+$ on the Devanagari number ४२?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: \d matches every decimal digit, so ४२ matches

    Without (*UCP), PCRE2 counts only the ASCII digits as \d. With it, \d follows the Unicode decimal-digit category, so the Devanagari four and two match.

  2. Question 2 of 2 Under (*LIMIT_MATCH=100000), what happens to \((?:[^()]+|(?R))*\) on (aaaaaaaaaaaaaaaa(b)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It stops with error -47, match limit exceeded

    A match limit counts the matcher's calls. The greedy form needs 753,665 on this subject, so a cap of 100,000 stops it with error -47, while the possessive form finishes in 22.

When a pattern should become a parser

Recursion handles one kind of bracket at a time, and when the text is broken it says only No match. A configuration value with (), [] and {}, or a user who needs to know where the mistake is, asks for more. A short Python function does both:

A parser for balanced brackets Python · parser/parser.py
"""A parser for balanced brackets: clearer than recursion, portable to any language."""
BRACKETS = {"(": ")", "[": "]", "{": "}"}
CLOSE = set(BRACKETS.values())

def check_balanced(text: str) -> tuple[bool, str]:
    """Return (True, '') if balanced, else (False, error message with position)."""
    stack: list[tuple[str, int]] = []
    for pos, ch in enumerate(text, 1):
        if ch in BRACKETS:
            stack.append((ch, pos))
        elif ch in CLOSE:
            if not stack:
                return False, f"Unexpected '{ch}' at position {pos}"
            opener, open_pos = stack.pop()
            if BRACKETS[opener] != ch:
                return False, f"'{ch}' at position {pos} does not match '{opener}' at {open_pos}"
    if stack:
        opener, pos = stack[-1]
        return False, f"Unclosed '{opener}' at position {pos}"
    return True, ""

samples = ["(a[b{c}d]e)", "(a[b)c]", "{[()]}(", "hello"]
for s in samples:
    ok, msg = check_balanced(s)
    if ok:
        print(f"{s!r:20} balanced")
    else:
        print(f"{s!r:20} {msg}")

Output

'(a[b{c}d]e)'        balanced
'(a[b)c]'            ')' at position 5 does not match '[' at 3
'{[()]}('            Unclosed '(' at position 7
'hello'              balanced

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parser.py

The parser keeps a stack of open brackets. Each closing bracket must match the most recent opener, and when the text ends, any opener left on the stack is unclosed. (a[b)c] fails with ')' at position 5 does not match '[' at 3, and {[()]}( reports the unclosed ( at position 7: positions that a pattern cannot give. The same function ports to every language, which matters because most constructs of this lesson do not. The runs below compile every pattern of the lesson in CPython, Go and Java 25:

Which constructs CPython, Go and Java compile

Python 3.14.8 · refusals/refusals.py

"""Which advanced PCRE2 constructs CPython's re compiles.

Each pattern is compiled once; an error is printed exactly as re reports it.
"""

import re

PATTERNS = [
    ("conditional", r"^(<)?\w+(?(1)>)$"),
    ("recursion", r"\((?:[^()]++|(?R))*\)"),
    ("recursion, no ++", r"\((?:[^()]|(?R))*\)"),
    ("branch reset", r"(?|(cat)|(dog))"),
    ("verbs", r'"[^"]*"(*SKIP)(*FAIL)|\w+'),
]

for label, pattern in PATTERNS:
    try:
        re.compile(pattern)
        verdict = "compiles"
    except re.error as error:
        verdict = f"re.error: {error}"
    print(f"{label}  {pattern}")
    print(f"  -> {verdict}")

Output

conditional  ^(<)?\w+(?(1)>)$
  -> compiles
recursion  \((?:[^()]++|(?R))*\)
  -> re.error: unknown extension ?R at position 14
recursion, no ++  \((?:[^()]|(?R))*\)
  -> re.error: unknown extension ?R at position 12
branch reset  (?|(cat)|(dog))
  -> re.error: unknown extension ?| at position 1
verbs  "[^"]*"(*SKIP)(*FAIL)|\w+
  -> re.error: nothing to repeat at position 8

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refusals.py

Go 1.27.1 · refusals/refusals.go

// Which advanced PCRE2 constructs Go's regexp package (RE2 syntax) compiles.
// Each pattern is compiled once; an error is printed exactly as Go reports it.
package main

import (
	"fmt"
	"regexp"
)

func main() {
	patterns := []struct{ label, pattern string }{
		{"conditional", `^(<)?\w+(?(1)>)$`},
		{"recursion", `\((?:[^()]++|(?R))*\)`},
		{"recursion, no ++", `\((?:[^()]|(?R))*\)`},
		{"branch reset", `(?|(cat)|(dog))`},
		{"verbs", `"[^"]*"(*SKIP)(*FAIL)|\w+`},
	}
	for _, p := range patterns {
		verdict := "compiles"
		if _, err := regexp.Compile(p.pattern); err != nil {
			verdict = err.Error()
		}
		fmt.Printf("%s  %s\n", p.label, p.pattern)
		fmt.Printf("  -> %s\n", verdict)
	}
}

Output

conditional  ^(<)?\w+(?(1)>)$
  -> error parsing regexp: invalid or unsupported Perl syntax: `(?(`
recursion  \((?:[^()]++|(?R))*\)
  -> error parsing regexp: invalid nested repetition operator: `++`
recursion, no ++  \((?:[^()]|(?R))*\)
  -> error parsing regexp: invalid or unsupported Perl syntax: `(?R`
branch reset  (?|(cat)|(dog))
  -> error parsing regexp: invalid or unsupported Perl syntax: `(?|`
verbs  "[^"]*"(*SKIP)(*FAIL)|\w+
  -> error parsing regexp: missing argument to repetition operator: `*`

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run refusals.go

Java 25.0.4 · refusals/Refusals.java

// Which advanced PCRE2 constructs java.util.regex compiles.
// Each pattern is compiled once; an error is printed with the description and index of PatternSyntaxException.
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

public class Refusals {
    public static void main(String[] args) {
        String[][] patterns = {
            {"conditional", "^(<)?\\w+(?(1)>)$"},
            {"recursion", "\\((?:[^()]++|(?R))*\\)"},
            {"recursion, no ++", "\\((?:[^()]|(?R))*\\)"},
            {"branch reset", "(?|(cat)|(dog))"},
            {"verbs", "\"[^\"]*\"(*SKIP)(*FAIL)|\\w+"},
        };
        for (String[] p : patterns) {
            String verdict;
            try {
                Pattern.compile(p[1]);
                verdict = "compiles";
            } catch (PatternSyntaxException e) {
                verdict = e.getDescription() + " (index " + e.getIndex() + ")";
            }
            System.out.println(p[0] + "  " + p[1]);
            System.out.println("  -> " + verdict);
        }
    }
}

Output

conditional  ^(<)?\w+(?(1)>)$
  -> Unknown inline modifier (index 10)
recursion  \((?:[^()]++|(?R))*\)
  -> Unknown inline modifier (index 15)
recursion, no ++  \((?:[^()]|(?R))*\)
  -> Unknown inline modifier (index 13)
branch reset  (?|(cat)|(dog))
  -> Unknown inline modifier (index 2)
verbs  "[^"]*"(*SKIP)(*FAIL)|\w+
  -> Dangling meta character '*' (index 8)

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Refusals.java

CPython compiles the conditional and nothing else. Go stops at the first item it does not know in each pattern: (?(, the ++, (?R, (?| and the * of the verb. Java 25 compiles none of them: four end with Unknown inline modifier at the character after (?, and the verbs with Dangling meta character '*'. Together with the JavaScript runs above, those records fill the table below. Choose whether the code must run outside PCRE2 to see which constructs remain:

Interactive · answer and compare Which engines compile which construct? Choose whether the code must run outside PCRE2 to see which constructs remain
Must the code also run outside PCRE2?

Answer the question above: the table then marks what fits your case and says why the rest does not.

Four advanced PCRE2 constructs in five engines, from the runs of this lesson; PHP follows the PCRE2 column
Construct JavaScript Go CPython re Java 25 PCRE2
Conditional Syntax: (?(1)yes|no) Poor SyntaxError: Invalid group Poor invalid or unsupported Perl syntax Good Compiles and matches Poor Unknown inline modifier Good Compiles and matches
Recursion Syntax: (?R) or (?0) Poor Refused: no ++ and no (?R) Poor Refused: no ++ and no (?R) Poor unknown extension ?R Poor Unknown inline modifier Good Matches (a(b)c)
Branch reset Syntax: (?|(a)|(b)) Poor Not in the ECMAScript grammar Poor invalid or unsupported Perl syntax Poor unknown extension ?| Poor Unknown inline modifier Good Both alternatives in group 1
Skip and fail Syntax: (*SKIP)(*FAIL) Poor Not in the ECMAScript grammar Poor Refused at the * of the verb Poor nothing to repeat Poor Dangling meta character '*' Good Skips the quoted words
How the answers decide
  • “Must the code also run outside PCRE2?” Yes: JavaScript, Go or Java: rules out Conditional, Recursion, Branch reset and Skip and fail. None of the four compiles in JavaScript, Go or Java 25; write two plain patterns, a capture group or a parser

The recursive pattern fits when the code runs only on PCRE2 or PHP, the brackets are of one kind and a yes-or-no answer is enough. The parser fits everywhere else: several kinds of bracket, messages with positions, or code that must run in any language.

Key idea

Recursion is powerful but narrow. A parser is clearer, runs in every language and says where the brackets go wrong.

Checkpoint

Check: Parser or pattern

  1. One question When is a short parser the better choice than a recursive pattern for brackets?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • The user needs to know where the mistake is
    • Three kinds of bracket must be checked together
    • The code must also run in JavaScript or Go

    A recursive pattern fits one kind of bracket on PCRE2 with a yes-or-no answer. Several kinds, error positions or other engines all point to a parser.

Practise

Thirteen questions cover the six sections, from reading a conditional to choosing between a recursive pattern and a parser. Each pick shows its reason at once, so a wrong pick points to the engine or the rule it missed.

Practice that adapts to you

Practise this lesson

13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Why does ^(<)?\w+(?(1)>)$ reject <broken?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: Group 1 captured <, so a > is required, and none follows

    Group 1 matched the <, so the condition (?(1)>) holds and the yes part, >, must come next. It does not, and $ cannot match before it, so the whole subject fails.

  2. Question 2 of 13 A Go service must accept word and <word> but not <word. RE2 has no conditionals. Which rewrite keeps the rule?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: ^(?:<\w+>|\w+)$

    Without conditionals, list the allowed shapes as alternatives: <\w+> or \w+, anchored at both ends. The pattern compiles in every engine of this lesson and gives the same three verdicts.

  3. Question 3 of 13 On (aaaaaaaaaaaaaaaa(b), the possessive pattern needs 22 match calls and the greedy one 753,665. What explains the gap?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: The greedy form tries every way of splitting the run of letters before the first ( gives up

    [^()]+ inside (...)* can divide a run of letters in many ways, and an unclosed bracket makes the engine try them all. [^()]++ takes the run in one piece, so 16 letters cost 22 calls instead of 753,665.

  4. Question 4 of 13 Why does V8 report Nothing to repeat for \((?:[^()]++|(?R))*\) instead of complaining about (?R)?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: JavaScript has no possessive quantifiers, so the second + has nothing to repeat

    V8 stops at the first construct it does not know. The ++ comes before (?R), so its error is Nothing to repeat; the second run, without ++, reaches (?R and reports Invalid group.

  5. Question 5 of 13 In PCRE2 10.49, ((\w+)@\w+) (?1(2)) matches ann@x bob@y. What is in group 2, and why?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: bob, because (?1(2)) returns group 2 from the call

    A plain (?1) restores group 2 to ann when it returns. Since PCRE2 10.47, (?1(2)) names group 2 as a value to return, so it ends up holding bob, as the recorded run shows.

  6. Question 6 of 13 Without branch reset, what does pcre2test show for group 1 when (cat)|(dog) matches dog?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: <unset>, with dog in group 2

    In (cat)|(dog) the second alternative's bracket is group 2, so a match on dog leaves group 1 unset. That is the two-places problem that branch reset removes.

  7. Question 7 of 13 A PCRE2 pattern (?|(\d{4})-\d\d|\d\d/(\d{4})) takes a year from either date form. How do you port it to CPython?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Drop the ?|, keep both groups, and read whichever one is set

    CPython has no branch reset. The same alternatives without ?| number the years as groups 1 and 2, and code that reads whichever group is set returns the year in both cases.

  8. Question 8 of 13 What does (*FAIL) do on its own?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: It fails at once, so its alternative never matches

    (*FAIL) makes the current path fail, as a branch that can never match. On its own it only forces backtracking; paired with (*SKIP) it throws away the text matched before it.

  9. Question 9 of 13 A Python service must count the words outside double quotes, as "[^"]*"(*SKIP)(*FAIL)|\w+ does in PCRE2. Which approach works in CPython?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Match "[^"]*"|(\w+) and count only the matches whose group 1 is set

    CPython has no verbs, but an alternation does the same job: the quoted string is matched and dropped by one alternative, and the words come from the capture group of the other.

  10. Question 10 of 13 Without (*UCP), does ^\d+$ match the Devanagari number ४२ in PCRE2?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: No: \d means the ten ASCII digits there

    PCRE2 recognises only ASCII digits for \d by default. The run shows No match for ४२ without (*UCP) and a match with it.

  11. Question 11 of 13 A program sets a match limit of 10,000. Can a pattern that starts with (*LIMIT_MATCH=50000) raise it?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: No: a pattern can only lower the limit its caller set

    (*LIMIT_MATCH=n) can only tighten a limit. A value above the caller's limit has no effect, so the program keeps control of how much work one match may do.

  12. Question 12 of 13 What does the lesson's parser report for (a[b)c]?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: ) at position 5 does not match [ at 3

    The parser pushes ( and [, then meets ) while [ is on top of the stack. It stops there and reports both positions, which a pattern cannot do.

  13. Question 13 of 13 A PHP-only tool checks one kind of bracket in short configuration values and needs a yes-or-no answer. Which fits best?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The recursive pattern with ++

    The recursive pattern is the right tool in its narrow case: PCRE2 or PHP only, one kind of bracket, and a yes-or-no answer. Keep the ++, so that broken input stays cheap.

Interview questions

Warm-up: when should you replace a recursive regex for balanced brackets with a short parser?

Even where PCRE2’s (?R) works, a recursive pattern has limits. It handles one kind of bracket at a time; a configuration file with (), [] and {} needs a far more complex pattern. It gives no error message when the text is unbalanced: the pattern either matches or it does not. And it is not portable: CPython, JavaScript, Go and Java refuse the syntax. A loop that reads one character at a time with a stack that checks each closer against the most recent opener runs in every language, reports which bracket is wrong, and handles all three kinds at once. The rule of thumb: when the answer must be more than yes or no, or the code must run outside PCRE2, PHP and Perl, write the parser.

Three questions take these constructs into code that ships: a delivery-field rule whose condition is a lookahead, a chat-log redactor built on (*SKIP)(*FAIL), and a recursive pattern that starts failing after a container image update. Pick your opening step for each one, then open it:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A PHP form checks a delivery field with ^(?(?=\d)\d{6}|[A-Z]{2}\d{4})$: a six-digit PIN code, or else a store code such as KA1234. The same rule must run in a Python service and in the browser. Why does neither accept the pattern, and what single pattern gives the same verdicts in all three?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A PHP chat-log redactor runs preg_replace('/ORD-\d+(*SKIP)(*FAIL)|\d{10}/', '[phone]', $line) to mask phone numbers but keep order numbers such as ORD-9123456789. What does it print for ORD-9123456789, 919876543210 and ord-9123456789, and how would you port it to Python's re.sub?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    Your CI pipeline runs integration tests against a PHP service. After a container image update, tests that use a recursive pattern for balanced parentheses start failing sporadically. The pattern \((?:[^()]++|(?R))*\) matched in production for two years. What changed, and how do you diagnose and fix it?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does the conditional (?(1)yes|no) match, and which engines run it?

    Its yes part when group 1 took part in the match so far, its no part otherwise. CPython and PCRE2 run it; JavaScript, Go and Java 25 refuse it, so portable code tests the group after a plain match.

  2. Which engines match balanced brackets with \((?:[^()]++|(?R))*\)?

    PCRE2 and PHP. CPython stops at (?R) with unknown extension, Java 25 calls it an unknown inline modifier, and JavaScript and Go fail earlier, at the possessive ++.

  3. Why does the recursive bracket pattern use [^()]++ rather than [^()]+?

    On one unclosed bracket with 16 letters, the greedy form cost 753,665 match calls, because each run of letters can be split many ways. The possessive form reads each run one way only and needed 22.

  4. What does the branch reset group (?|(cat)|(dog)) do?

    It gives every alternative the same group numbers, so the animal is always in group 1. Outside PCRE2 the portable form reads whichever group is set.

  5. What does (*SKIP)(*FAIL) do in a PCRE2 pattern?

    It consumes the text matched before it and never reports it, so, for example, words inside quotes are skipped. Engines without verbs get the same result from an extra group and code that keeps only the matches that set it.

  6. What do the start-of-pattern items (*UCP) and (*LIMIT_MATCH=n) change?

    They set options for the whole match: (*UCP) makes \d take every decimal digit, and (*LIMIT_MATCH=n) caps the work, so a runaway match stops with error -47.

  7. When should a recursive bracket pattern give way to a short parser?

    When the brackets come in several kinds, when an error must say where it is, or when the code must run outside PCRE2, PHP and Perl. A loop with a stack runs in every language and names the bracket that is wrong.

Key takeaways

  • Conditional patterns such as (?(1)yes|no) match their yes part when group 1 took part in the match and their no part otherwise; CPython and PCRE2 run them, while JavaScript, Go and Java 25 refuse them.
  • Recursion (?R) lets PCRE2 and PHP match balanced brackets such as (a(b)c), while CPython, JavaScript, Go and Java cannot compile it; the possessive ++ keeps it fast, at 22 match calls against 753,665 for the greedy form on one unclosed bracket.
  • Branch reset (?|(cat)|(dog)) gives every alternative the same group numbers, so the animal is always in group 1; outside PCRE2 the portable form reads whichever group is set.
  • (*SKIP)(*FAIL) consumes text and throws it away, so the words inside quotes are never reported; in other engines an alternation with a capture group does the same job.
  • Start-of-pattern items such as (*UCP) and (*LIMIT_MATCH=n) set options for the whole match: every decimal digit for \d, and a cap that turns a runaway match into error -47.
  • When the brackets come in several kinds, when errors need positions, or when the code must run outside PCRE2, a short parser is clearer than any of these patterns.

Terms in this lesson

backtracking-control verb
An item written (*NAME), such as (*FAIL) or (*SKIP), that tells a backtracking engine what to do at that point: (*FAIL) fails at once, and (*SKIP) makes the next attempt start where it stood.
branch reset group
A group written (?|...) in which every alternative numbers its capture groups from the same starting number, so one group number holds the result whichever alternative matched.
conditional pattern
A group written (?(condition)yes|no) that matches the yes part when the condition holds, for example when group 1 took part in the match, and the no part otherwise.
recursion
A call written (?R) or (?0) that runs the whole pattern again at the current position, inside the match in progress, so a pattern can match nested structures such as balanced brackets.
start-of-pattern item
An option written at the very start of a PCRE2 pattern, such as (*UCP) or (*LIMIT_MATCH=n), that changes how the whole pattern is compiled or matched.
subroutine call
A call such as (?1) or (?&name) that runs the pattern of one group again at the current position, as a program calls a function; groups it captures are put back to their old values when it returns.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.