Regex for Repeated Characters: (.)\1+ in 4 Engines
Find and collapse runs of the same character with (.)\1+ in .NET, Python, PHP and Dart: how it differs from a+, emoji pitfalls, and a 1M-character timing.
Revised November 2026. This tutorial first appeared in Korean in 2021, where it introduced
+,*and?through examples such as\w+@\w+\.\w+andPlayer[0-9][0-9]*. We re-ran all six examples with identical inputs in .NET 10, Python 3.9 and 3.12re, PHP 8.5 (PCRE2 10.47, with and without/u) and Dart 3.11, whoseRegExpfollows JavaScript rules. The matches agreed in every engine. One definition was wrong for half the engines:\wis not[a-zA-Z0-9_]in .NET, Python or PHP with/u. The quantifier theory now lives in our quantifier article; this version focuses on what the title promises: finding runs of the same character.
This article is for anyone who needs to detect or shorten repeated characters: soooo, !!!!, PIN codes like 1111, or laughter in chat logs. The short answer is (.)\1+: capture one character, then require that exact character one or more times. A plain quantifier cannot do this, because a+ only repeats a character you name in advance and .+ accepts any mix of characters. The method relies on a backreference, which our backreferences article covers in depth.
The 2021 examples, re-checked
Here are the original examples with the results every engine returned (inputs one per line):
| Pattern | Input | Matches |
|---|---|---|
\w+@\w+\.\w+ |
id@gmail.com, id@daumnet, id@naver.com, id@nate.com, id@google |
the three addresses with a dot |
bo+ |
b, bo, boo |
bo, boo |
bo* |
b, bo, boo |
b, bo, boo |
Player[0-9][0-9]* |
Player1 β¦ Player100, Player_, Player@ |
every name with at least one digit, Player100 in full |
Player[0-9][0-9]? |
same | the same names, but Player10 from Player100 |
aabb?cc |
aabcc, aabbcc, aabbbcc |
aabcc, aabbcc |
The last row deserves a remark: the 2021 page showed aabbcc twice, and the third line was probably aabcc before formatting was lost, since that is the case b? exists to accept. Note also that Player[0-9][0-9]? does not reject Player100; it matches the first ten characters. If the number must be at most two digits, end the pattern with \b: on Player1, Player100, Player12, the pattern Player[0-9][0-9]?\b matched only Player1 and Player12 in every engine.
Two points from the 2021 text needed correcting.
\w is not always [a-zA-Z0-9_]. On the input νκΈΈλ@μμ.νκ΅, \w+@\w+\.\w+ matched the whole address in .NET, Python and PHP with /u, and nothing in PHP without /u or in Dart. Our character set article has the full table.
The email pattern is a demonstration, not a validator. On first.last@mail.example.co.jp every engine returned last@mail.example: \w does not include the dot, so the local part and the domain are both cut short. For real validation, check for one @ and send a confirmation mail.
Matching the same character again: (.)\1+
(.) captures any one character into group 1. \1 then demands the text group 1 captured, and + lets that repeat. Together they find runs of length two or more:
Pattern: (.)\1+
Text: aaabccddddd
Matches: aaa (group 1 = a), cc (c), ddddd (d)
All four engines agreed, in both modes. Compare it with the patterns people try first:
| Pattern | Text | Result | What it means |
|---|---|---|---|
.+ |
aab |
aab |
any characters, same or not |
(.)+ |
abc |
abc, group 1 = c |
repeats the group; the group keeps only the last character |
a{3} |
aab aaab |
aaa |
three of one character you wrote |
(.)\1\1 |
aab aaab |
aaa |
three of whatever character comes first |
(.)\1+ |
aab aaab |
aa, aaa |
two or more of the same character |
The (.)+ row is a common trap. A quantified group does not "remember" all its characters; group 1 holds only the last iteration, c. .NET keeps the history in Groups[1].Captures (a|b|c in our run), but no other engine exposes it in its standard library.
Swap . for something narrower to choose which runs count:
(\w)\1+ on "100% cool --- yes" β 00, oo (the dashes are ignored)
(\d)\1{3,} on "PIN 1111, 1234, 00007" β 1111, 0000
(.)\1+ on "AaA bB" (case-insensitive) β AaA, bB
The last line shows that with case-insensitive matching on, the backreference comparison also ignores case in every engine we tested.
Collapsing repeats with a replacement
Finding runs is usually the first step toward shortening them. The replacement refers back to group 1, and the syntax depends on the engine:
| Goal | Pattern | .NET / PHP | Python | Result |
|---|---|---|---|---|
| one of each | (.)\1+ |
$1 |
\1 |
aaabccddddd β abcd |
| at most two | (.)\1{2,} |
$1$1 |
\1\1 |
soooo good!!!! β soo good!! |
Every engine produced the same output. Dart's replaceAll has no replacement syntax, so we used replaceAllMapped(re, (m) => m[1]! * 2), which gave the same string. The {2,} version is the one you usually want for user text: good book soooo became good book soo, so normal double letters survive and only runs of three or more are trimmed.
If you write \1 in a .NET replacement, or $1 in Python, the engine does not complain; it inserts the characters literally. The backreferences article has the full table of which spelling each engine accepts.
Emoji and other characters outside the BMP
The same pattern stopped agreeing when the input contained emoji:
| Text | .NET | Python 3 | PHP no /u |
PHP /u |
Dart | Dart unicode: true |
|---|---|---|---|---|---|---|
ππ ok |
no match | ππ |
no match | ππ |
no match | ππ |
πππ |
no match | πππ |
no match | πππ |
no match | πππ |
γ
γ
γ
γ
μ’μμ!!! |
γ
γ
γ
γ
, !!! |
same | !!! only |
same as .NET | same as .NET | same as .NET |
.NET strings, like JavaScript strings, are UTF-16. An emoji such as π is a surrogate pair, two code units ("ππ".Length is 4 in C#), and . matches only one of them. Group 1 captures the first half, the next unit is the second half, and they differ, so there is no run. Python works with code points, and Dart and PHP do too once Unicode mode is on. PHP without /u works on bytes, which even breaks three-byte Hangul jamo.
In .NET, capture a surrogate pair as one unit:
Regex.Match("ππ", @"([\uD800-\uDBFF][\uDC00-\uDFFF]|.)\1+").Value // "ππ"
Emoji with skin-tone modifiers or joiners are several code points long, so even this only handles the simple ones. For a feature like "collapse repeated emoji in comments", split the text into grapheme clusters first (StringInfo in .NET, the characters package in Dart) and compare clusters in code.
How fast is it? A one-million-character run
To see whether a regex is a reasonable tool for cleaning large text, we generated 1,000,000 characters of chat-like text (a fixed seed, words such as soooo, !!!!, γ
γ
γ
γ
and wwww mixed with normal words) and replaced (.)\1{2,} with two copies of group 1. There were 78,072 runs; the output was 859,424 characters in every case. Three runs each, Apple Silicon Mac:
| Method | Time |
|---|---|
Python 3.12 re.sub |
34.1β35.2 ms |
Python 3.12 itertools.groupby loop (same output) |
175 ms |
.NET 10 Regex.Replace, default |
89β100 ms |
.NET 10 Regex.Replace, RegexOptions.Compiled |
20.5β23.1 ms |
The regex is not the bottleneck at this size, and in Python it was five times faster than the obvious hand-written loop. Unlike the nested quantifiers in our quantifier article, (.)\1+ cannot blow up: each run is consumed once and there is nothing ambiguous to backtrack over.
Cheat sheet and how we tested
| Task | Pattern |
|---|---|
| Any run of 2+ same characters | (.)\1+ |
| Runs of 3+ | (.)\1{2,} |
| Same digit 4+ times (weak PIN) | (\d)\1{3,} |
| Letters and digits only | (\w)\1+ (Unicode-aware in .NET, Python, PHP /u) |
| Collapse to one | replace (.)\1+ with group 1 |
| Collapse to two | replace (.)\1{2,} with group 1 twice |
| Emoji-safe in .NET | ([\uD800-\uDBFF][\uDC00-\uDFFF]|.)\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, with and without /u), and Dart 3.11.5 (RegExp.allMatches, replaceAllMapped, default and unicode: true). Node.js was not installed, so JavaScript rules were checked through Dart. Patterns and inputs came from one JSON file, so every engine read the same bytes.
Tested on macOS on an Apple Silicon Mac, November 2026.
FAQ
How do I shorten "soooo good!!!!" to "soo good!!"?
Replace (.)\1{2,} with two copies of group 1. In .NET and PHP the replacement is $1$1, in Python \1\1, and in Dart replaceAllMapped with (m) => m[1]! * 2. All engines returned "soo good!!".
Why does (.)\1+ not find "ππ" in C#?
.NET strings are UTF-16 and . matches one code unit, so an emoji is two units and the backreference compares the wrong halves. "ππ" has Length 4 in C#. Python, PHP with /u and Dart with unicode: true matched it. In .NET, ([\uD800-\uDBFF][\uDC00-\uDFFF]|.)\1+ matched "ππ".
What is the difference between a+ and (.)\1+?
a+ repeats one specific character you wrote in the pattern. (.)\1+ captures whatever character appears and requires that same character again, so it finds runs of any character. .+ is different again: it matches any characters, the same or not, so on "aab" it returned "aab" while (.)\1+ returned "aa".