Regex Character Sets: [abc], Ranges and \p{L} Tested
Regex character sets tested in .NET, Python, PHP and Dart: ranges, negation, escaping in [ ], what \d and \w really match, \p{L}, .NET subtraction.
Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran every pattern from it with byte-identical inputs in .NET 10 (System.Text.RegularExpressions), Python 3.9 and 3.12
re, PHP 8.5 (PCRE2 10.47, with and without/u) and Dart 3.11, whoseRegExpfollows JavaScript rules. The four original examples produced the documented matches in every engine. Two statements needed correcting: ranges are not limited to ASCII, and\wis not[a-zA-Z0-9_]in .NET or Python. Unicode classes, escaping rules and engine differences are new.
This article is for anyone who writes a bracket expression like [0-9] or [^,] and wants to know exactly which characters it lets through, especially once the input contains Korean, Japanese, full-width digits or emoji. A character set always matches exactly one character. What changes from engine to engine is which characters count as "in the set", and that is where portable-looking patterns quietly diverge.
If the metacharacters themselves (., \, ^, $) are new to you, start with our metacharacter article; this one stays inside the brackets.
One position, one character: the basics re-checked
The first 2021 example:
Pattern: f[ie]
Text: fire / siren / female / finally / apple / airport
Result: "fi" (fire), "fe" (female), "fi" (finally)
All engines agreed. [ie] does not mean "i followed by e". It is a single slot that accepts i or e, so f[ie] is always exactly two characters long.
The second example looked for file names with a digit in the fourth position, first spelled out and then as a range:
Pattern: ...[0123456789] same as ...[0-9]
Text: abcd.txt abce.txt abc0.txt abcf.txt abc1.txt abc2.txt abc3.txt (one per line)
Result: abc0, abc1, abc2, abc3
Both spellings returned the same four matches everywhere. A hyphen between two characters makes a range, and several ranges can share one set: [a-zA-Z0-9].
The third example negates the set with a caret right after the opening bracket:
Pattern: ...[^0-9]\.
Result: "abcd.", "abce.", "abcf."
Again identical in every engine. Note that [^0-9] still consumes one character; it means "one character that is not a digit", not "no digit here". It also matches a newline, which surprises people who use [^,]+ on multi-line text: on a,b + newline + c,d, every engine returned b + newline + c as a single match. Exclude the newline explicitly with [^,\n]+, which gave a, b, c, d everywhere.
Ranges are code points, not "ASCII only"
The 2021 article said a range can hold "any ASCII character" and that the smaller value must come first. The second part is true. A reversed range is a compile error in every engine, with these messages:
.NET : Invalid pattern '[z-a]' at offset 4. [x-y] range in reverse order.
Python : bad character range z-a at position 1
PHP : Compilation failed: range out of order in character class at offset 4
Dart : FormatException: Range out of order in character class
The first part is not. A range compares code points, so it works for any script:
| Pattern | Text | .NET / Python / PHP /u / Dart |
PHP without /u |
|---|---|---|---|
[가-힣]+ |
한글 test 漢字 ㅋㅋ |
한글 |
한글, 漢字, ㅋㅋ |
[ぁ-ん]+ |
ひらがなとカタカナ |
ひらがなと |
the whole string |
The PHP column is the trap. Without /u, PCRE works on bytes, so [가-힣] becomes a set of UTF-8 byte values and matches pieces of unrelated CJK characters. Any PHP pattern that contains non-ASCII text needs the /u modifier.
Code point order also explains a classic mistake: [A-z] is not "all letters". Between Z (0x5A) and a (0x61) sit [ \ ] ^ _ and the backtick, and every engine matched Az_^[`]\ as one run. Write [A-Za-z].
Escaping inside the brackets
Most metacharacters lose their meaning inside a set, which is why [.] matches a literal dot in every engine. Only a few characters are special in there, and only in certain positions:
| Pattern | Text | Result (all engines) | Why |
|---|---|---|---|
[.] |
a.b |
. |
. is literal inside a set |
[a-]+ |
a-b |
a- |
- at the end is literal |
[a\-z]+ |
a-zb |
a-z |
escaped - is literal, so this is three characters, not a range |
[a^]+ |
a^b |
a^ |
^ is only special right after [ |
[\]a]+ |
a]b |
a] |
escaped ] |
[\d.]+ |
v1.2.3 |
1.2.3 |
shorthand classes work inside sets |
One spelling did not agree. Many references say ] placed first in a set is literal:
Pattern on a]b |
.NET | Python | PHP | Dart (JS rules) | Dart unicode: true |
|---|---|---|---|---|---|
[]a]+ |
a] |
a] |
a] |
no match | FormatException: Lone quantifier brackets |
In JavaScript-style engines [] is an empty set that can never match, so the same text means something completely different. Escape it as \]; that worked in all five configurations.
POSIX classes are another non-portable spelling. [[:alpha:]]+ on ab12:cd returned ab and cd only in PHP. Python warned FutureWarning: Possible nested set at position 1 and matched nothing; .NET and Dart matched nothing.
What \d, \w and \s really match
The 2021 article defined \w as [a-zA-Z0-9_]. That is true for some engines and false for others. We ran the same three inputs everywhere:
| Pattern | Text | .NET | Python 3 | PHP /u |
PHP no /u |
Dart |
|---|---|---|---|---|---|---|
\d+ |
123 123 ١٢٣ |
all three | all three | all three | 123 |
123 |
\w+ |
café 한글 abc |
café, 한글, abc |
same | same | caf, abc |
caf, abc |
a\sb |
a + NBSP + b |
match | match | match | no match | match |
So in .NET and Python \d accepts full-width and Arabic-Indic digits. That matters when a form field is validated with ^\d+$ and the value is then passed to int.Parse or a database column: the regex says yes, the next step may disagree. When you mean ASCII digits, say so:
- write
[0-9](identical results in every engine), or - .NET:
RegexOptions.ECMAScript(then\d+returned only123,\w+onlycaf), or - Python:
re.ASCII(same:['123']and['caf']).
Dart's RegExp, like JavaScript, keeps \d and \w ASCII-only even with unicode: true, so a Flutter app and its .NET or Python backend can disagree on the same pattern.
Unicode properties: \p{L}, blocks and scripts
When you really want "any letter", use a Unicode property instead of listing ranges. Support is uneven, and the error messages are the useful part:
Pattern on café 한글 123 / abc 한글 ㅋㅋ |
.NET | Python re |
PHP /u |
Dart unicode: true |
|---|---|---|---|---|
\p{L}+ |
café, 한글 |
error | café, 한글 |
café, 한글 |
\p{IsHangulSyllables}+ (block) |
한글 |
error | error | error |
\p{Hangul}+ (script) |
error | error | 한글, ㅋㅋ |
error |
\p{Script=Hangul}+ |
error | error | 한글, ㅋㅋ |
한글, ㅋㅋ |
The actual messages:
Python 3.9 / 3.12 re : bad escape \p at position 0
.NET 10 : Invalid pattern '\p{Hangul}+' at offset 10. Unknown property 'Hangul'.
PHP 8.5 : unknown property after \P or \p at offset 21 (for IsHangulSyllables)
Dart 3.11 : FormatException: Invalid property name \p{IsHangulSyllables}+
Two details worth knowing. First, .NET uses Unicode blocks (Is + block name), while PCRE and JavaScript use scripts. A block is a fixed code point range: IsHangulSyllables does not include the compatibility jamo ㅋ, which lives in IsHangulCompatibilityJamo (\p{IsHangulCompatibilityJamo}+ returned ㅋㅋ in .NET). The script Hangul covers both. Japanese works the same way: \p{IsHiragana}+|\p{IsKatakana}+ returned ひらがな and カタカナ in .NET. Second, Dart without unicode: true silently treats \p{L} as the letter p followed by {L}, so it matched nothing instead of failing. Always set unicode: true when a Dart pattern uses \p.
For Python, install the third-party regex package. With regex 2026.11.9 on Python 3.12, regex.findall(r'\p{L}+', 'café 한글 123') returned ['café', '한글'], and \p{Hangul}+ returned ['한글', 'ㅋㅋ'].
.NET class subtraction and the portable alternative
.NET can subtract one set from another: [a-z-[aeiou]] means "lowercase letters except vowels".
Pattern: [a-z-[aeiou]]+
Text: regex b] works
.NET: r, g, x, b, w, rks
It also works with shorthands and properties: [\w-[\d_]]+ on A1 b2 c3 _9 returned A, b, c, and [\p{L}-[\p{IsCJKUnifiedIdeographs}]]+ on 한글 漢字 カナ returned 한글 and カナ.
The risk is what other engines do with the same text. Python, PHP and Dart (default mode) compiled it without an error and read it as the set [a-z-[aeiou] followed by a literal ], so the only match was b]. Dart in unicode: true mode at least refused it. A pattern that silently changes meaning is worse than one that fails, so for shared patterns use a lookahead inside a repeated group:
(?:(?![aeiou])[a-z])+ → r, g, x, b, w, rks in all engines
Python's regex module offers real set operations with the V1 flag: (?V1)[[a-z]--[aeiou]]+ returned the same six matches.
Quick reference and test setup
| You want | Portable pattern | Notes |
|---|---|---|
| ASCII digit | [0-9] |
\d is Unicode-aware in .NET, Python, PHP /u |
| ASCII letter | [A-Za-z] |
never [A-z] |
Literal ], -, ^ in a set |
\], \-, \^ |
escaping is the safe habit |
| Any letter, any script | \p{L} |
not in Python re; Dart needs unicode: true |
| Hangul only | .NET \p{IsHangulSyllables}; PCRE \p{Hangul}; JS \p{Script=Hangul} |
blocks vs scripts differ |
| Set minus set | (?:(?![aeiou])[a-z]) |
[a-z-[aeiou]] is .NET only |
Engines: .NET 10.0.14 (Regex.Matches), Python 3.9.6 and 3.12.14 (re.finditer), PHP 8.5.6 with PCRE2 10.47 (preg_match_all, with and without /u), and Dart 3.11.5 (RegExp.allMatches, default and unicode: true). Node.js was not installed, so JavaScript rules were checked through Dart. All patterns and inputs came from one JSON file so every engine saw the same bytes. For the related quantifier rules see regex quantifiers.
Tested on macOS on an Apple Silicon Mac, November 2026.
FAQ
Does \d only match 0 to 9?
Not everywhere. In .NET, Python 3 and PHP with the /u flag, \d also matched full-width digits (123) and Arabic-Indic digits (١٢٣). Only PHP without /u and Dart (JavaScript rules) stayed ASCII-only. If you mean ASCII digits, write [0-9], or use RegexOptions.ECMAScript in .NET or re.ASCII in Python.
Can Python re use \p{L} like .NET?
No. Python 3.9 and 3.12 re both raised "bad escape \p at position 0". The third-party regex module (2026.11.9 in our test) accepted \p{L}, \p{Hangul} and set operations such as [[a-z]--[aeiou]] with the V1 flag.
Is [a-z-[aeiou]] portable?
No. It is .NET character class subtraction. In Python, PHP and Dart the same text parsed without error but meant something else and matched "b]" instead of consonants. A portable spelling is (?:(?![aeiou])[a-z])+, which gave the same matches in all engines.