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.
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:
"""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:
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: 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- Match a, reach (. The [^()]++ matches the letter a. At position 2 the engine sees ( and cannot match it with [^()] or advance further without recursion.
- 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.
- Match b, reach ). Inside the recursive call, [^()]++ matches b. At position 4 the engine sees ) which ends this level.
- Return from depth 1. The ) closes the inner group. The recursive call succeeds with (b) and returns to depth 0.
- Match c, reach ). Back at depth 0, [^()]++ matches c. At position 6 the engine sees ) which ends the outer group.
- 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 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 · 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
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 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
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:
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
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 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
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: 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:
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:
Answer the question above: the table then marks what fits your case and says why the rest does not.
| 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
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.
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.
-
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? -
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 forORD-9123456789,919876543210andord-9123456789, and how would you port it to Python's re.sub? -
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?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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 ++.
-
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.
-
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.
-
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.
-
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.
-
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
- pcre2pattern: Conditional groups (PCRE2 Project)
- pcre2pattern: Recursive patterns and subroutine calls (PCRE2 Project)
- pcre2pattern: Duplicate group numbers (branch reset) (PCRE2 Project)
- pcre2pattern: Backtracking control and start-of-pattern items (PCRE2 Project)
- pcre2test: the find_limits modifier (PCRE2 Project)
- PCRE2 10.49 ChangeLog (10.47: recursion that returns capture groups) (PCRE2 Project)
- re: Regular expression syntax (Python) (Python Software Foundation)
- perlre: Perl regular expressions (Perl Foundation)
- ECMAScript 2026 (ECMA-262 17th edition): Patterns (Ecma International (TC39))
- RE2 syntax (Google)
- Java SE 25 Pattern class (Oracle)
- mrab-regex: the regex module for Python (recursive patterns) (Matthew Barnett)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress