Regex Backreferences in .NET, Python, PHP and JS
Regex backreferences (\1, \k<name>) tested in .NET, Python, PHP and a JS-syntax engine: doubled words, paired quotes, syntax differences, backtracking cost.
Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran its patterns with identical inputs in .NET 10 (System.Text.RegularExpressions), Python 3.9 and 3.12
re, PHP 8.5 (PCRE2 10.47) and Dart 3.11, whoseRegExpfollows JavaScript syntax. Both original examples produced the documented matches in every engine. Two explanations did not hold up: the claim that a two-digit backreference to a missing group is always a syntax error (true only in Python), and the description of\bas "the same as a space". Engine differences and a backtracking measurement are new.
This article is for anyone who wants a regex to find a word typed twice in a row, or an opening tag with its matching closing tag, and for anyone who moved a pattern from Python to .NET or PHP and watched the backreference syntax break. The key idea: \1 does not mean "the same pattern as group 1". It means "exactly the text group 1 just captured". Named groups, replacement strings and groups that did not participate all behave differently across engines, so re-test whenever a pattern changes engine.
What a backreference matches
Parentheses capture whatever they matched into a numbered group. Inside the same pattern, \1, \2 and so on demand that captured text again, character for character. Here is the first example from the original article:
Pattern: \b([a-z]+) \1\b
Text: why so so serious
All four engines found exactly one match, so so. The engine starts at why, stores why in group 1, and checks whether a space plus why follows. The next word is so, so that attempt fails and the engine moves on. At the first so, group 1 holds so, the text after the space is also so, and the match succeeds.
The original article said \b could be thought of as the space here. That is not accurate. The pattern's literal space character matches the space; \b is a zero-width position between a word character and a non-word character. Its job is to stop the pattern from matching part of a word. Here is what happens without the leading \b:
Pattern: ([a-z]+) \1
Text: this is it
Result: "is is" (the "is" at the end of "this", then the word "is")
All four engines returned is is, a false positive. For a real doubled-word check, put \b on both sides and ignore case:
Pattern: \b(\w+)\s+\1\b (case-insensitive)
Text: The the cat sat on the the mat
Result: "The the", "the the"
With case-insensitive matching turned on, the backreference comparison also ignores case, so The the is caught in all four engines. Running this pattern over about one million characters of English-like text took 65–72 ms on .NET 10 and found 22,720 doubled words, which is cheap enough for a pre-publish lint script.
The heading-tag example, re-checked
The second 2021 example finds <H1> to <H6> headings whose closing tag carries the same number:
Pattern: <H([1-6])>.*?</H\1>
Text: <H1>Text</H1>
<H2>Text</H3>
<H3>Text</H3><H4>Text</H4>
Result: <H1>Text</H1> / <H3>Text</H3> / <H4>Text</H4>
As described, only the mismatched <H2>Text</H3> is skipped. One detail: the original highlighted the whole third line as one match, but <H3>…</H3> and <H4>…</H4> are two separate matches.
The article also said the greedy .* would work just as well here. For that text it does. Put two headings of the same level on one line and it no longer does:
Pattern: <H([1-6])>.*</H\1>
Text: <H1>a</H1> <H1>b</H1>
Result: "<H1>a</H1> <H1>b</H1>" (merged into one)
.* runs to the end of the line and backs off only as far as the last </H1>. Use the lazy .*?, and add case-insensitive matching if lowercase <h1> should count. Real HTML has attributes, nesting and comments, so keep regexes like this for logs and quick template checks and use an HTML parser for actual documents.
A practical one: paired quotes
Backreferences shine when something must close with the same symbol it opened with. Here we extract only properly paired strings from text that mixes single and double quotes:
Pattern: (["']).*?\1
Text: say "it's fine" and 'ok'
Result: "it's fine" / 'ok'
Group 1 captured ", so the apostrophe in it's does not close the string; the match runs to the next ". With ["'].*?["'] instead, the first match would have stopped at "it'. All four engines agreed.
Where engines differ (observed, not assumed)
The concept is shared; the syntax and the edge cases are not. Node.js was not installed on the test machine, so JavaScript behaviour was checked with Dart's RegExp, which implements ECMAScript regex syntax, as in our lookaround article.
Named groups. Four spellings, each looking for 'ok' in x 'ok' y:
| Pattern | .NET | Python 3.9 / 3.12 | PHP (PCRE2) | Dart (JS syntax) |
|---|---|---|---|---|
(?<q>["'])\w+\k<q> |
match | error unknown extension ?<q |
match | match |
(?P<q>["'])\w+(?P=q) |
error Unrecognized grouping construct |
match | match | error Invalid group |
(?<q>…) with (?P=q) |
error | error | match | error |
(?P<q>…) with \k<q> |
error | error bad escape \k |
match | error |
PCRE2 accepts all four, which is how a pattern that worked in PHP breaks elsewhere. Moving from Python to .NET means (?P<name> becomes (?<name> and (?P=name) becomes \k<name>.
Replacement strings. We collapsed the doubled word in so so serious with \b(?<w>\w+) \k<w>\b (Python: (?P<w>…)(?P=w)) and varied only the replacement:
| Replacement | .NET Regex.Replace |
Python re.sub |
PHP preg_replace |
|---|---|---|---|
$1 |
so serious |
$1 serious (literal) |
so serious |
\1 |
\1 serious (literal) |
so serious |
so serious |
${1} |
so serious |
literal | so serious |
\g<1> |
literal | so serious |
literal |
${w} (name) |
so serious |
literal | literal |
\g<w> (name) |
literal | so serious |
literal |
The dangerous part is that the wrong spelling is not an error. It is copied in as literal text, so unless you look at the output you will save strings like $1 serious. Dart's replaceAll has no replacement syntax at all (everything stayed literal); you use replaceAllMapped with a callback instead. Browser JavaScript's String.prototype.replace uses $1 and $<name>, but we could not run it here.
Groups that did not participate. In (a)?b\1 applied to b, group 1 never captured anything.
| Engine | (a)?b\1 on b |
alternation (?:(a)|b)\1 on b |
|---|---|---|
| .NET / Python / PHP | no match | no match |
| Dart (JS syntax) | b |
b |
JavaScript-style engines treat a reference to a non-participating group as an empty string and let it succeed. Patterns that back-reference an optional group can change meaning when you switch engines.
Missing groups and two-digit numbers. The 2021 article said a two-digit reference is a backreference if that group exists and a parse error otherwise. We tested (a)\10, which has only one group. Only Python raised an error (invalid group reference 10). .NET, PHP and Dart read \10 as an octal escape for backspace (U+0008) and matched a followed by a backspace. For (a)\2, .NET (Reference to undefined group number 2), Python and PHP raised errors, while Dart silently found nothing (and raised an error in unicode: true mode). With ten or more groups, prefer an unambiguous form such as .NET's \k<10> or named groups.
Backtracking cost, measured, and timeouts
A backreference rules out .NET's linear-time engine. On .NET 10:
NotSupportedException: RegexOptions.NonBacktracking is not supported in conjunction
with expressions containing: 'backreference (\ number)'.
So backreference patterns always run on the backtracking engine, and a badly shaped one can explode with input length. We compared two patterns for "a line whose first and last words are the same". First we confirmed both match so so, so it so and the cat ran the, and reject sox so and so also.
var timeout = TimeSpan.FromSeconds(2);
var bad = new Regex(@"^(\w+)\b(?:\s*\w+)*\s+\1$", RegexOptions.None, timeout);
var good = new Regex(@"^(\w+)\b.*\s\1$", RegexOptions.None, timeout);
string input = "the " + new string('a', n) + "!"; // the trailing ! guarantees no match
// RegexMatchTimeoutException is caught and recorded as TIMEOUT
In bad, the \s* inside (?:\s*\w+)* can be empty, so the engine tries every way of cutting the run of as into "words" before giving up. Two runs on .NET 10, Release, Apple Silicon Mac:
| n (letters) | bad run 1 |
bad run 2 |
good |
|---|---|---|---|
| 16 | 5.7 ms | 4.8 ms | 0.01 ms |
| 20 | 77.1 ms | 75.1 ms | < 0.01 ms |
| 22 | 302.7 ms | 302.8 ms | 0.01 ms |
| 24 | 1,241.4 ms | 1,215.5 ms | 0.01–0.05 ms |
| 26 and up | timeout (2,000 ms) | timeout | 0.01 ms |
| 1,000 | timeout | timeout | 0.03 ms |
Every two extra letters cost about four times as much. Python 3.9's re followed nearly the same curve on the same input (4.2, 68.5, 279.8 and 1,121.3 ms), and since re has no timeout option it would simply hang on longer input. If you run backreference patterns on user-supplied text in .NET, pass a matchTimeout and handle RegexMatchTimeoutException. The real fix is structural: avoid a quantifier nested inside a quantifier where the separator between items is optional, as in bad.
Cheat sheet and how we tested
| Task | Pattern |
|---|---|
| Doubled words | \b(\w+)\s+\1\b, case-insensitive |
| Paired quotes | (["']).*?\1 |
| Matching open/close heading tags | <H([1-6])>.*?</H\1> (lazy) |
| Backreference by name | .NET and JS (?<n>…)\k<n> / Python (?P<n>…)(?P=n) / PHP either |
| Group in a replacement | .NET $1, ${n} / Python \1, \g<n> / PHP $1, \1 |
Engines: .NET 10.0.14 (Regex.Matches, Regex.Replace), Python 3.9.6 and 3.12.14 (re.finditer, re.sub), PHP 8.5.6 with PCRE2 10.47 (preg_match_all, preg_replace), and Dart 3.11.5 (RegExp.allMatches, default and unicode: true). Patterns and inputs lived in one JSON file that every engine read, so the inputs were byte-identical. There is no single regex standard; the only reliable habit is to test in the engine your code actually runs on.
Tested on macOS on an Apple Silicon Mac, November 2026.
FAQ
Should I write $1 or \1 in a replacement string?
It depends on the engine. .NET Regex.Replace understands $1, ${1} and ${name}, and leaves \1 as literal text. Python re.sub understands \1, \g<1> and \g<name>, and leaves $1 as literal text. PHP preg_replace accepted $1, \1 and ${1}, but not a name such as ${w}.
What happens when a backreference points to a group that did not participate?
Applying (a)?b\1 to "b" gave no match in .NET, Python and PHP (PCRE2). Dart's RegExp, which follows JavaScript semantics, matched "b", because JavaScript treats a backreference to a non-participating group as an empty string.
Can I use RegexOptions.NonBacktracking with a backreference?
No. On .NET 10, constructing \b(\w+) \1\b with RegexOptions.NonBacktracking threw NotSupportedException. Backreferences need the backtracking engine, so give the Regex a matchTimeout when the input is untrusted.