Regular Expressions Module 2 – Characters, escapes and character classes
Character classes, ranges and negation
Match one character from a set with classes, ranges and negation, place ], - and ^ safely, and see where POSIX names and set operations work.
What you will learn
- Build character classes with ranges and negation, and work out from code points what a range covers
- Write ], - and ^ inside a class so that JavaScript, Python, PCRE2 and Go all read them as plain characters
- Recognise where POSIX bracket expressions such as [[:alpha:]] work, and where they fail without an error
- Use class set operations with the v flag of JavaScript and in PCRE2, and name the Java and .NET forms
Before you start
What you’ll learn · Before you start
What you will learn
- Build character classes with ranges and negation, and work out from code points what a range covers
- Write ], - and ^ inside a class so that JavaScript, Python, PCRE2 and Go all read them as plain characters
- Recognise where POSIX bracket expressions such as [[:alpha:]] work, and where they fail without an error
- Use class set operations with the v flag of JavaScript and in PCRE2, and name the Java and .NET forms
Before you start
On this page
A hex colour such as #C0FFEE has six positions, and each one may hold any of 22 characters: the ten digits, A to F
and a to f. Writing the 22 choices out for every position would be unreadable; a
character class: A part of a pattern between square brackets that matches exactly one character of the text: any one of the characters, ranges and classes listed inside it, such as [aeiou] for one vowel. says the same in eleven characters, [0-9A-Fa-f].
Below you will build classes from single characters and ranges, turn a class inside out with a caret, and meet the
places where flavour: A particular regular-expression syntax and feature set, such as ECMAScript, Python re, PCRE2 or RE2; a pattern is written for one of them. More in “What a regular expression is and where it runs” part ways: JavaScript reads []a] as a class that can never
match, [A-z] takes six symbols besides the letters, [[:alpha:]] means “a letter” in PCRE2 and Go but quietly
matches nothing in JavaScript, and “a to z except the vowels” can be written as one class in some flavours only.
Each example shows what its engine printed: JavaScript, Python, PCRE2, Go or PHP.
A class matches one character from a set
British English writes grey and American English gray. The pattern gr[ae]y finds both: g and r match
themselves, then the class [ae] matches one character that is either a or e, then y matches the y. A class is
one position of the pattern, never more: it does not match graey, where two letters stand between r and y, and it does
not match gry, where there is none. The order of the members does not matter, so [ea] is the same class, and a
member written twice changes nothing: [aa] is [a]. A class chooses between single characters; a choice between
longer pieces, such as gray or grey or graey, is alternation with |, which a later module covers.
Inside the brackets the dot, the star and most other metacharacter: A character that carries a special meaning in a regex pattern. ECMAScript lists fourteen outside a character class: ^ $ \ . * + ? ( ) [ ] { } and |. A backslash before any one of them makes it literal. More in “Literals, metacharacters and the dot” are plain
characters, so [.*] matches a dot or a star. A class can also hold the shorthand classes such as \d, which the
next lesson covers.
A caret straight after the opening bracket turns the set inside out. [^0-9] is a
negated class: A character class that starts with a caret right after the opening bracket, such as [^0-9]. It matches exactly one character that is not in the set, line breaks included.: one character that is not a digit. It is still one character, so it
needs a character to look at, and it treats a line terminator: A character that ends a line. ECMAScript lists four (U+000A, U+000D, U+2028 and U+2029). Python, Go and PCRE2 treat the line feed alone as the line terminator by default. The dot refuses line terminators without a dotAll flag. More in “Literals, metacharacters and the dot” like any other
character. That is the difference from the dot, which stops at line breaks. Run the program and compare the last two
lines:
// A character class matches ONE character from its set; [^…] matches one character outside it.
function show(pattern, text) {
const m = text.match(pattern);
const found = m ? `${JSON.stringify(m[0])} at ${m.index}` : 'no match';
console.log(`${pattern} in ${JSON.stringify(text)}: ${found}`);
}
// One position, two allowed letters: a or e.
for (const word of ['grey', 'gray', 'graey', 'gry']) show(/gr[ae]y/, word);
// Negation: the first character that is NOT a digit.
show(/[^0-9]/, '4021-7');
// A negated class still needs a character: after the 7 there is none.
show(/7[^0-9]/, '4021-7');
// The dot stops at a newline; a negated class does not.
show(/a.b/, 'a\nb');
show(/a[^x]b/, 'a\nb'); Output
/gr[ae]y/ in "grey": "grey" at 0 /gr[ae]y/ in "gray": "gray" at 0 /gr[ae]y/ in "graey": no match /gr[ae]y/ in "gry": no match /[^0-9]/ in "4021-7": "-" at 4 /7[^0-9]/ in "4021-7": no match /a.b/ in "a\nb": no match /a[^x]b/ in "a\nb": "a\nb" at 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node class-basics.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
[^0-9] found the hyphen at position 4, the first character that is not a digit. 7[^0-9] found nothing, because the
7 is the last character and nothing follows it for the class to take. And a[^x]b matched across the line break that
stopped a.b. PCRE2’s documentation makes the same point for its own classes, with [^a] as the example: whatever
line ending is in force, that class matches a line break.
Key idea
A class matches exactly one character at one position: any of its members, or, with a caret first, any character that is not a member, line breaks included.
Checkpoint
Check: A class matches one character
Ranges follow code points, not the alphabet
[A-z] looks like “any letter, capital or small”, and it accepts the underscore. A hyphen between two characters in
a class makes a character range: Two characters joined by a hyphen inside a character class, such as a-z. It matches every character whose code point lies between the two, both included, so A-z also takes the six symbols between Z and a., and ECMAScript defines a range as every character
whose value lies in the interval from the first to the last, both included. Python, PCRE2 and Go use the same rule.
The value is the character’s code point: The number the Unicode standard assigns to a character, independent of how the character is stored in memory. More in “What a regular expression is and where it runs”, and in Unicode the capitals and the small letters
are not neighbours: six symbols stand between Z and a, as the figure shows row by row.
What [A-z] covers: every code point from U+0041 to U+007A
Text description of the diagram
Three rows of boxes, each box a character with its code point in hexadecimal under it.
The first row is the capital letters A to Z, code points U+0041 to U+005A: A 41, B 42, C 43, then a gap for the letters D to X, then Y 59 and Z 5A. That is 26 letters.
The second row, drawn with dashed outlines, is the six characters that come next in Unicode, between Z and a: the opening square bracket 5B, the backslash 5C, the closing square bracket 5D, the caret 5E, the underscore 5F and the backtick (grave accent) 60. None of them is a letter.
The third row is the small letters a to z, code points U+0061 to U+007A: a 61, b 62, c 63, a gap for d to x, then y 79 and z 7A. That is 26 more letters.
The line at the bottom adds them up: the range A-z takes all 58 code points from U+0041 to U+007A, 26 + 6 + 26, while the two ranges A-Z and a-z together take only the 52 letters.
The figure lays out the 58 code points from U+0041 to U+007A. The program tests each character around the range against
[A-z] and against [A-Za-z], the class that names its two ranges separately, then counts both over all of ASCII:
// A range covers every code point from its first character to its last, so [A-z] holds more than letters.
const wide = /[A-z]/;
const letters = /[A-Za-z]/;
const hex = (ch) => 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
const yes = (re, ch) => (re.test(ch) ? 'yes' : 'no');
console.log('char code point [A-z] [A-Za-z]');
for (const ch of '@AZ[\\]^_`az{') {
console.log(`${ch.padEnd(6)}${hex(ch).padEnd(12)}${yes(wide, ch).padEnd(7)}${yes(letters, ch)}`);
}
// Count over all 128 ASCII characters.
let inWide = 0;
let inLetters = 0;
for (let cp = 0; cp < 128; cp++) {
const ch = String.fromCodePoint(cp);
if (wide.test(ch)) inWide++;
if (letters.test(ch)) inLetters++;
}
console.log(`ASCII characters in [A-z]: ${inWide}, in [A-Za-z]: ${inLetters}`);
// A range must run upwards: a (U+0061) comes after Z (U+005A), so [a-Z] is refused.
try {
new RegExp('[a-Z]');
console.log('[a-Z] compiles');
} catch (e) {
console.log(`[a-Z]: ${e.name}`);
} Output
char code point [A-z] [A-Za-z]
@ U+0040 no no
A U+0041 yes yes
Z U+005A yes yes
[ U+005B yes no
\ U+005C yes no
] U+005D yes no
^ U+005E yes no
_ U+005F yes no
` U+0060 yes no
a U+0061 yes yes
z U+007A yes yes
{ U+007B no no
ASCII characters in [A-z]: 58, in [A-Za-z]: 52
[a-Z]: SyntaxError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node range-az.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
[A-z] said yes to [, the backslash, ], ^, _ and the backtick, and [A-Za-z] said no to all six: 58
characters against 52. The last line shows the other way a range goes wrong. [a-Z] runs backwards, from U+0061 down
to U+005A, and every flavour of this lesson refuses it when the pattern is compiled. Press Edit, put your own
characters in the string the loop reads, such as a colon or an é, and run it again.
The safe habit is one range per block of characters you want, all inside one class. The ends of a range may also be
code-point escapes, the forms the lesson on literals and the dot compared: [\u0900-\u097F] is the whole Devanagari
block in JavaScript and in Python. A hex digit is three blocks, so [0-9A-Fa-f] holds three ranges and nothing else. Written
once per position after a #, it finds six-digit and three-digit colours:
// Hex colours: one class for one hex digit, written once per digit.
const HEX = '[0-9A-Fa-f]';
const six = new RegExp('#' + HEX.repeat(6));
const three = new RegExp('#' + HEX.repeat(3));
console.log('six digits: ', String(six));
console.log('three digits:', String(three));
const theme = ['#FF8800', '#0aF', '#12G4F0', '#fff', '#C0FFEE', '#1e90ff'];
for (const colour of theme) {
const a = colour.match(six);
const b = colour.match(three);
console.log(`${colour.padEnd(8)} six: ${(a ? a[0] : '-').padEnd(8)} three: ${b ? b[0] : '-'}`);
} Output
six digits: /#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]/ three digits: /#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]/ #FF8800 six: #FF8800 three: #FF8 #0aF six: - three: #0aF #12G4F0 six: - three: - #fff six: - three: #fff #C0FFEE six: #C0FFEE three: #C0F #1e90ff six: #1e90ff three: #1e9
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hex-colours.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
#12G4F0 failed both patterns at the G, and every six-digit colour matched. The three-digit pattern also found #FF8
at the start of #FF8800, because nothing tells it where a colour ends. A later module adds anchors and counted
repetition, which stop that and write the six classes as one.
Key idea
A range takes every code point from its first character to its last, so give each block its own range, as in
[A-Za-z], and check what lies between the ends.
Checkpoint
Check: Ranges cover code points
Inside a class, escape the bracket, the hyphen and the caret
Inside a class only four characters have a job, as
Literals, metacharacters and the dot listed: ] ends the class, the
backslash escapes, ^ negates when it comes first, and - makes a range when it
stands between two characters. To match one of them as itself, the flavours offer two ways: a position where the job
cannot apply, or a backslash. The positions are where they disagree. Step through how JavaScript and three other
engines read the same five characters, []a]+:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The same five characters. JavaScript and three other engines get the same pattern, []a]+, and read it from left to right. In all of them the opening bracket starts a character class. What happens at the next character, a closing bracket, splits them in two.
- JavaScript closes at once. JavaScript ends a class at the first ] it meets, even straight after the opening bracket. So [] is a class with nothing in it, which ECMAScript defines as the empty set: it matches no character at all.
- The rest is plain text. Everything after the empty class is outside any class: a is a literal a, the second ] is a literal ], and + repeats that ]. JavaScript reads the pattern as no character, then a, then one or more ].
- JavaScript never matches. A pattern that must first match a character from an empty set can never match, whatever the text. On x]a] JavaScript reports no match, and it gives no error or warning.
- A first ] is a member. Python, PCRE2 and Go take a ] straight after the opening bracket as a member of the class, as POSIX does. The class goes on to the next ], so it holds two characters: ] and a.
- + repeats the class. Here + follows the whole class, so the pattern means one or more characters in a row, each of them a ] or an a.
- Three engines find ]a]. On x]a] the search finds no match at the x, then takes ], a and ] in a row from position 1: the match is ]a]. Written as [\]a]+, with the bracket escaped, the pattern finds ]a] in all four engines.
Python, PCRE2 and Go follow the POSIX rule that a ] right after the opening bracket is a member. JavaScript ends a
class at the first ] wherever it stands, and ECMAScript defines the empty class [] as a set with no characters,
which never matches. The same six probes ran in all four engines; the first three rows are about placement:
JavaScript (Node 24.21.0) · class-traps/class-traps.mjs
// Six class probes, the same in every engine of this lesson: JavaScript (V8) here.
const probes = [
['[]a]+', 'x]a]'],
['[\\]a]+', 'x]a]'],
['[+^-]', '2+3^4-1'],
['[A-z]+', 'Hello_[World]'],
['[[:alpha:]]+', 'ab1 cd'],
['a[^x]b', 'a\nb'],
];
for (const [pattern, text] of probes) {
let result;
try {
const found = text.match(new RegExp(pattern, 'g'));
result = found ? JSON.stringify(found) : 'no match';
} catch (e) {
result = `${e.name}: ${e.message}`;
}
console.log(`${pattern.padEnd(13)} on ${JSON.stringify(text).padEnd(16)} -> ${result}`);
} Output
[]a]+ on "x]a]" -> no match [\]a]+ on "x]a]" -> ["]a]"] [+^-] on "2+3^4-1" -> ["+","^","-"] [A-z]+ on "Hello_[World]" -> ["Hello_[World]"] [[:alpha:]]+ on "ab1 cd" -> no match a[^x]b on "a\nb" -> ["a\nb"]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node class-traps.mjs
Python 3.14.8 · class-traps/class_traps.py
"""Six class probes, the same in every engine of this lesson: Python's re here.
re raises FutureWarning for a class that may change meaning in a later version; the warnings module catches it,
so the message prints beside its probe.
"""
import json
import re
import warnings
PROBES = [
(r"[]a]+", "x]a]"),
(r"[\]a]+", "x]a]"),
(r"[+^-]", "2+3^4-1"),
(r"[A-z]+", "Hello_[World]"),
(r"[[:alpha:]]+", "ab1 cd"),
(r"a[^x]b", "a\nb"),
]
for pattern, text in PROBES:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
found = re.findall(pattern, text)
result = json.dumps(found, ensure_ascii=False, separators=(",", ":")) if found else "no match"
print(f"{pattern:<13} on {json.dumps(text):<16} -> {result}")
for warning in caught:
print(f"{'':<13} {warning.category.__name__}: {warning.message}") Output
[]a]+ on "x]a]" -> ["]a]"]
[\]a]+ on "x]a]" -> ["]a]"]
[+^-] on "2+3^4-1" -> ["+","^","-"]
[A-z]+ on "Hello_[World]" -> ["Hello_[World]"]
[[:alpha:]]+ on "ab1 cd" -> no match
FutureWarning: Possible nested set at position 1
a[^x]b on "a\nb" -> ["a\nb"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 class_traps.py
Go 1.27.1 · class-traps/class_traps.go
// Six class probes, the same in every engine of this lesson: Go's regexp (RE2 syntax) here.
// The last line runs the newline probe again with CompilePOSIX, whose syntax flags leave out ClassNL.
package main
import (
"encoding/json"
"fmt"
"regexp"
)
func show(pattern, text string, compile func(string) (*regexp.Regexp, error)) {
quoted, _ := json.Marshal(text)
result := "no match"
re, err := compile(pattern)
if err != nil {
result = err.Error()
} else if found := re.FindAllString(text, -1); found != nil {
list, _ := json.Marshal(found)
result = string(list)
}
fmt.Printf("%-13s on %-16s -> %s\n", pattern, quoted, result)
}
func main() {
probes := [][2]string{
{`[]a]+`, "x]a]"},
{`[\]a]+`, "x]a]"},
{`[+^-]`, "2+3^4-1"},
{`[A-z]+`, "Hello_[World]"},
{`[[:alpha:]]+`, "ab1 cd"},
{`a[^x]b`, "a\nb"},
}
for _, p := range probes {
show(p[0], p[1], regexp.Compile)
}
fmt.Println("The newline probe again, compiled with CompilePOSIX:")
show(`a[^x]b`, "a\nb", regexp.CompilePOSIX)
} Output
[]a]+ on "x]a]" -> ["]a]"] [\]a]+ on "x]a]" -> ["]a]"] [+^-] on "2+3^4-1" -> ["+","^","-"] [A-z]+ on "Hello_[World]" -> ["Hello_[World]"] [[:alpha:]]+ on "ab1 cd" -> ["ab","cd"] a[^x]b on "a\nb" -> ["a\nb"] The newline probe again, compiled with CompilePOSIX: a[^x]b on "a\nb" -> no match
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run class_traps.go
PCRE2 10.49 · class-traps/class_traps_pcre2.py
"""Six class probes, the same in every engine of this lesson: PCRE2 10.49 here, through its test program pcre2test.
Each pattern line ends in /g (find every match); the indented line under it is the text, and each match prints as
" 0: <text>". In a text line, \\n stands for a newline, and pcre2test shows a matched newline as \\x0a.
"""
from pcre2_session import run
run(r'''
/[]a]+/g
x]a]
/[\]a]+/g
x]a]
/[+^-]/g
2+3^4-1
/[A-z]+/g
Hello_[World]
/[[:alpha:]]+/g
ab1 cd
/a[^x]b/g
a\nb
''') Output
$ pcre2test -q patterns.txt (PCRE2 10.49)
/[]a]+/g
x]a]
0: ]a]
/[\]a]+/g
x]a]
0: ]a]
/[+^-]/g
2+3^4-1
0: +
0: ^
0: -
/[A-z]+/g
Hello_[World]
0: Hello_[World]
/[[:alpha:]]+/g
ab1 cd
0: ab
0: cd
/a[^x]b/g
a\nb
0: a\x0ab
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 class_traps_pcre2.py
Row one is the split from the figure: no match in JavaScript, ]a] in the other three. Row two escapes the bracket,
and all four agree. Row three, [+^-], works everywhere because the caret is not first and the hyphen is last. Row
four repeats the range rule of the last section in all four engines: [A-z]+ took the underscore and both brackets
of Hello_[World] into a single match. The position rules of row three hold until a pattern is edited: a member
added after the hyphen turns it into a range, and a caret moved to the front negates the class. The table sums up how
the two camps read each case:
| Inside a class | JavaScript | Python, PCRE2 and Go |
|---|---|---|
] right after [ |
ends the class | a member |
- first or last |
a member (an error with the v flag) | a member |
^ anywhere but first |
a member | a member |
\], \- or \^ |
a member | a member |
The last row is the one to remember: escape all three, [\]\-\^], and every engine of this lesson reads the class the
same way, JavaScript with the v flag included. An escaped caret that is not first was never needed, but it costs
nothing and keeps its meaning when someone later moves it to the front. One exception lies outside these engines.
POSIX tools such as grep read a backslash inside brackets as a plain backslash, so there the standard’s rule applies:
put ] first and - last. A later module on tools covers grep.
Checkpoint
Check: Placing ], - and ^ in a class
POSIX names work in PCRE2 and Go, and fail silently in JavaScript
Row five of the same probes, [[:alpha:]]+ on the text ab1 cd, found ab and cd in PCRE2 and Go and nothing at all in
JavaScript and Python. The double brackets are a POSIX bracket expression: The POSIX name for a [...] class. It may hold class names such as [:alpha:] or [:digit:], as in [[:alpha:]]; PCRE2, Go and grep support those names, while JavaScript and Python read them as ordinary characters.:
POSIX defines twelve class names that may stand inside a bracket expression: alnum, alpha, blank, cntrl, digit,
graph, lower, print, punct, space, upper and xdigit, each written between [: and :]. The outer brackets are the
class and the name is one member of it, so [[:alpha:][:digit:]] is a letter or a digit.
PCRE2 supports the names and gives them ASCII meanings unless Unicode properties are switched on (its UCP option), and
Go lists [[:alpha:]] as an ASCII class equal to [A-Za-z]. JavaScript and Python’s re have no POSIX names. They read
[[:alpha:] as an ordinary class of the characters [, :, a, l, p and h, and the last ] as a literal,
so a match needs one of those characters followed by a ]. Python at least says something: FutureWarning, Possible
nested set, because it reserves a class that starts with [ for nested classes in a later version.
What JavaScript makes of the same five characters depends on the flags. Run it with none, with u and with v:
// JavaScript has no POSIX names inside brackets. What it makes of [[:alpha:]] depends on the flags.
function show(label, source, flags, text) {
let result;
try {
const found = text.match(new RegExp(source, flags + 'g'));
result = found ? JSON.stringify(found) : 'no match';
} catch (e) {
result = e.name; // the wording of the message differs between engines
}
console.log(`${label.padEnd(8)} /${source}/${flags} on ${JSON.stringify(text)}: ${result}`);
}
show('no flag', '[[:alpha:]]+', '', 'ab1 cd');
show('no flag', '[[:alpha:]]+', '', 'a] :]');
show('u flag', '[[:alpha:]]+', 'u', 'ab1 cd');
show('v flag', '[[:alpha:]]+', 'v', 'ab1 cd');
show('v flag', '[[:alpha:]]+', 'v', 'alpha: ph');
show('ranges', '[A-Za-z]+', '', 'ab1 cd'); Output
no flag /[[:alpha:]]+/ on "ab1 cd": no match no flag /[[:alpha:]]+/ on "a] :]": ["a]",":]"] u flag /[[:alpha:]]+/u on "ab1 cd": SyntaxError v flag /[[:alpha:]]+/v on "ab1 cd": ["a"] v flag /[[:alpha:]]+/v on "alpha: ph": ["alpha:","ph"] ranges /[A-Za-z]+/ on "ab1 cd": ["ab","cd"]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node posix-js.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
Without flags the pattern quietly misses ab1 cd and matches the odd text a] :]. The u flag turns the lone ] into a
SyntaxError, which at least fails at once. The v flag accepts nested classes, so [:alpha:] becomes a class of five
characters and the pattern finds alpha: in the text alpha: ph. None of the three means “a letter”. To port a POSIX
name, write the set it stood for: [A-Za-z] for the ASCII meaning, or a property such as \p{L} when every script’s
letters should count.
Go's POSIX mode changes classes too
The last line of the Go tab compiled a[^x]b with regexp.CompilePOSIX, and the class no longer matched the line
break. Go’s documentation lists the flag ClassNL, which lets classes such as [^a-z] match a newline, among the
flags of its normal syntax and not of POSIX syntax.
Checkpoint
Check: POSIX bracket expressions
Set operations build one class from others
The lowercase consonants are a to z without five letters, and with ranges alone they become
[b-df-hj-np-tv-z], five ranges that are hard to read and easy to get wrong. Unicode’s regular expression standard,
UTS #18, asks engines for union, intersection and set difference inside a class. A
class set operation: Building a character class from other classes by difference, intersection or union, such as [[a-z]--[aeiou]] with JavaScript's v flag: a to z without the vowels. Each flavour that has set operations spells them its own way. builds the consonants from two simpler sets.
JavaScript gained them with the v flag in ECMAScript 2024: [[a-z]--[aeiou]] takes the vowels away from a to z, and
[[a-z]&&[^aeiou]] keeps what both sets share, which is the same set. Each operand is a whole class, a single
character or a property such as the Unicode property escape: A pattern escape such as \p{L} (any letter) or \p{Nd} (any decimal digit) that matches characters by a Unicode property instead of a list; \P matches the rest. JavaScript needs the u or v flag for it, and Python's re module lacks it. More in “Shorthand classes for digits, words and spaces” \p{L}, any
letter. Nesting alone is union: [[a-z][0-9]] with v is a letter from a to z or a digit. Press Edit, take y away
as well by writing [[a-z]--[aeiouy]], and run it again:
// Set operations inside a class need the v flag (ES2024).
const text = 'regular expression x--a]';
// Difference: the letters a to z, minus the five vowels.
console.log('difference ', JSON.stringify(text.match(/[[a-z]--[aeiou]]+/gv)));
// Intersection: the letters a to z that are also not vowels (the same set).
console.log('intersection', JSON.stringify(text.match(/[[a-z]&&[^aeiou]]+/gv)));
// Any letter of any script except a to z.
console.log('letters ', JSON.stringify('Hello, Wörld'.match(/[\p{L}--[a-z]]/gv)));
// Without v the same characters are an ordinary class, [[a-z], then --[aeiou]] as text.
console.log('no v flag ', JSON.stringify(text.match(/[[a-z]--[aeiou]]/g)));
// v mode also wants ( ) [ ] { } / - | escaped inside a class.
for (const source of ['[(]', '[\\(]', '[+-]', '[+\\-]']) {
try {
new RegExp(source, 'v');
console.log(`/${source}/v compiles`);
} catch (e) {
console.log(`/${source}/v ${e.name}`);
}
} Output
difference ["r","g","l","r","xpr","ss","n","x"] intersection ["r","g","l","r","xpr","ss","n","x"] letters ["H","W","ö"] no v flag ["x--a]"] /[(]/v SyntaxError /[\(]/v compiles /[+-]/v SyntaxError /[+\-]/v compiles
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node consonants-v.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
Without the v flag the same characters are an ordinary class followed by text, which matched x--a]. The v flag also
asks for more escapes inside a class: the proposal that added it lists (, ), [, {, }, /, - and | as
characters that must be escaped there, besides \ and ], so /[(]/v and /[+-]/v are SyntaxErrors and
[+\-] compiles. The flavours that have set operations spell them differently:
| Flavour | One lowercase consonant |
|---|---|
| JavaScript, v flag | [[a-z]--[aeiou]] or [[a-z]&&[^aeiou]] |
| PCRE2 10.45 or later | (?[ [a-z] - [aeiou] ]), or [[a-z]--[aeiou]] with the option PCRE2_ALT_EXTENDED_CLASS |
| Java | [a-z&&[^aeiou]], from its Pattern documentation |
| .NET | [a-z-[aeiou]], from its documentation of character class subtraction |
| Python, Go, PHP 8.5.11 | none: write the ranges, [b-df-hj-np-tv-z] |
The JavaScript, PCRE2, PHP, Python and Go rows ran; the tabs show what each printed for the same text:
JavaScript (V8) · consonants/consonants.mjs
// Consonants in JavaScript (V8, Node 24): set operations with the v flag, and what v mode refuses.
const text = 'regular expression x--a]';
for (const [source, flags] of [
['[[a-z]--[aeiou]]+', 'v'],
['[[a-z]--[aeiou]]', ''],
['[(]', 'v'],
['[a-z--aeiou]', 'v'],
]) {
try {
new RegExp(source, flags); // compiled without g first, so an error names the pattern as written
const found = text.match(new RegExp(source, flags + 'g'));
console.log(`/${source}/${flags} -> ${JSON.stringify(found)}`);
} catch (e) {
console.log(`/${source}/${flags} -> ${e.name}: ${e.message}`);
}
} Output
/[[a-z]--[aeiou]]+/v -> ["r","g","l","r","xpr","ss","n","x"] /[[a-z]--[aeiou]]/ -> ["x--a]"] /[(]/v -> SyntaxError: Invalid regular expression: /[(]/v: Invalid character in character class /[a-z--aeiou]/v -> SyntaxError: Invalid regular expression: /[a-z--aeiou]/v: Invalid set operation in character class
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node consonants.mjs
PCRE2 10.49 · consonants/consonants_pcre2.py
"""Consonants by set operations in PCRE2 10.49, through its test program pcre2test.
(?[ ... ]) is Perl's extended class, in PCRE2 from release 10.45; inside it, - takes one set away from another and
spaces are ignored. The alt_extended_class modifier sets PCRE2_ALT_EXTENDED_CLASS, which gives [...] itself the
UTS #18 operators --, && and ||. Without that option the same [...] is an ordinary class. Each match prints as " 0:".
"""
from pcre2_session import run
run(r'''
/(?[ [a-z] - [aeiou] ])+/g
regular expression x--a]
/[[a-z]--[aeiou]]+/g,alt_extended_class
regular expression x--a]
/[[a-z]--[aeiou]]/g
regular expression x--a]
/(?[ \p{L} - [a-z] ])/g,utf
Hello, W\x{f6}rld
''') Output
$ pcre2test -q patterns.txt (PCRE2 10.49)
/(?[ [a-z] - [aeiou] ])+/g
regular expression x--a]
0: r
0: g
0: l
0: r
0: xpr
0: ss
0: n
0: x
/[[a-z]--[aeiou]]+/g,alt_extended_class
regular expression x--a]
0: r
0: g
0: l
0: r
0: xpr
0: ss
0: n
0: x
/[[a-z]--[aeiou]]/g
regular expression x--a]
0: x--a]
/(?[ \p{L} - [a-z] ])/g,utf
Hello, W\x{f6}rld
0: H
0: W
0: \x{f6}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 consonants_pcre2.py
PHP 8.5.11 (PCRE2 10.44) · consonants/consonants.php
<?php
// Consonants in PHP 8.5, whose preg functions use the PCRE2 library bundled with PHP.
// A pattern that does not compile makes preg_match_all() return false and raise a warning; the handler below prints
// the warning's message, so nothing else (such as the script's path) is printed.
set_error_handler(function (int $level, string $message): bool {
echo " warning: $message\n";
return true;
});
echo 'PCRE2 ', explode(' ', PCRE_VERSION)[0], "\n";
$text = 'regular expression x--a]';
$patterns = ['/(?[ [a-z] - [aeiou] ])+/', '/[[a-z]--[aeiou]]/', '/[b-df-hj-np-tv-z]+/'];
foreach ($patterns as $pattern) {
$count = preg_match_all($pattern, $text, $found);
$result = $count === false ? 'false' : json_encode($found[0]);
echo "$pattern -> $result\n";
} Output
PCRE2 10.44 warning: preg_match_all(): Compilation failed: unrecognized character after (? or (?- at offset 2 /(?[ [a-z] - [aeiou] ])+/ -> false /[[a-z]--[aeiou]]/ -> ["x--a]"] /[b-df-hj-np-tv-z]+/ -> ["r","g","l","r","xpr","ss","n","x"]
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php consonants.php
Python 3.14.8 · consonants/consonants.py
"""Consonants in Python's re, which has no set operations yet.
re reads the set-operation syntax of other flavours (JavaScript's --, Java's &&) as ordinary characters of a class
and raises FutureWarning, because a later version may give it the UTS #18 meaning; the warnings module catches the
warning so its message prints under its pattern.
"""
import json
import re
import warnings
TEXT = "regular expression x--a]"
for pattern in (r"[[a-z]--[aeiou]]", r"[a-z&&[^aeiou]]", r"[b-df-hj-np-tv-z]+"):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
found = re.findall(pattern, TEXT)
print(f"{pattern} -> {json.dumps(found, separators=(',', ':'))}")
for warning in caught:
print(f" {warning.category.__name__}: {warning.message}") Output
[[a-z]--[aeiou]] -> ["x--a]"] FutureWarning: Possible nested set at position 1 [a-z&&[^aeiou]] -> ["a]"] FutureWarning: Possible set intersection at position 4 [b-df-hj-np-tv-z]+ -> ["r","g","l","r","xpr","ss","n","x"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 consonants.py
Go 1.27.1 · consonants/consonants.go
// Consonants in Go's regexp (RE2 syntax), which has no set operations.
package main
import (
"encoding/json"
"fmt"
"regexp"
)
func main() {
text := "regular expression x--a]"
for _, pattern := range []string{`(?[ [a-z] - [aeiou] ])+`, `[[a-z]--[aeiou]]`, `[b-df-hj-np-tv-z]+`} {
re, err := regexp.Compile(pattern)
if err != nil {
fmt.Printf("%s -> %v\n", pattern, err)
continue
}
found, _ := json.Marshal(re.FindAllString(text, -1))
fmt.Printf("%s -> %s\n", pattern, found)
}
} Output
(?[ [a-z] - [aeiou] ])+ -> error parsing regexp: invalid or unsupported Perl syntax: `(?[` [[a-z]--[aeiou]] -> ["x--a]"] [b-df-hj-np-tv-z]+ -> ["r","g","l","r","xpr","ss","n","x"]
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run consonants.go
PCRE2 inside PHP is older than pcre2test
PCRE2 gained both forms, Perl’s (?[ ... ]) and the UTS #18 operators of PCRE2_ALT_EXTENDED_CLASS, in release 10.45.
PHP 8.5.11 bundles PCRE2 10.44, so its PHP tab refuses the extended class with a compile warning and reads
[[a-z]--[aeiou]] as an ordinary class, just as Go does.
Python reads the JavaScript and the Java forms as ordinary classes too, and warns that a later version may give them meaning: Possible nested set, Possible set intersection. Of the five engines that ran, only JavaScript with v and PCRE2 10.49 found the consonants with a set operation; PHP, Python and Go found them only with the ranges written out. Where a flavour has no set operations, those ranges are the dependable fallback: write them, then test the characters at both ends of every range and the ones just outside them.
Key idea
Set operations make a class out of other classes, but each flavour spells them its own way and some have none, so a set operation copied into another flavour fails or silently changes meaning.
Checkpoint
Check: Class set operations
Practise: four classes, two flavours
Each task of the exercise names its flavour, because the two do not offer the same tools: in JavaScript the v flag lets you take digits or vowels away from a range, and in Python you write the ranges out yourself. The starter code holds one common mistake per task, and the sample tests run in your browser:
Exercise · Easy · JavaScript, Python
Four character classes in two flavours
Write four character classes, each matching exactly one character. Solve them in JavaScript (patterns.mjs, a regular expression literal for each name) or in Python (patterns.py, the pattern text for each name, which the tests compile with re). The two flavours differ in what they offer, so each task says what its flavour expects:
HEX_DIGIT: one hexadecimal digit,0to9oratofin either case. Both flavours.SPECIAL: one character that is a closing bracket], a hyphen-, a caret^or a backslash\, and no other character. Both flavours; the JavaScript tests also run it with thevflag added, so write it in a form that v mode accepts too.CONSONANT: one lowercase English consonant, a letter fromatozother thana,e,i,oandu. JavaScript: use thevflag and take the vowels away from[a-z]with set difference,--. Python:rehas no set operations, so write the consonants out as ranges; the tests fail on any warning.DEVANAGARI: one character of the Devanagari block, U+0900 to U+097F, that is not one of its ten digits, U+0966 to U+096F. JavaScript: thevflag and set difference again. Python: ranges.
The starter code holds a common mistake for every task. The sample tests list the characters each class must match and must not match.
JavaScript · Starter code · patterns.mjs
// Each name holds a character class that matches exactly one character. Each one below has a common mistake.
/** One hexadecimal digit, 0-9 or a-f in either case. */
export const HEX_DIGIT = /[0-9A-f]/;
/** One of ] - ^ \ and nothing else (the tests also try it with the v flag). */
export const SPECIAL = /[]-^\\]/;
/** One lowercase consonant, with the v flag and a set operation. */
export const CONSONANT = /[a-z]/v;
/** One Devanagari character (U+0900 to U+097F) that is not a Devanagari digit (U+0966 to U+096F). */
export const DEVANAGARI = /[\u{900}-\u{97F}]/v; The sample tests · patterns.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { CONSONANT, DEVANAGARI, HEX_DIGIT, SPECIAL } from './patterns.mjs';
// A copy without g or y, so that test() never carries a position over from one call to the next.
const fresh = (re) => new RegExp(re.source, re.flags.replace(/[gy]/g, ''));
// The same class with the v flag (in place of u, which cannot be combined with v).
const withV = (re) => new RegExp(re.source, re.flags.replace(/[gyuv]/g, '') + 'v');
function check(re, yes, no) {
const r = fresh(re);
for (const ch of yes) assert.ok(r.test(ch), `${String(re)} should match ${JSON.stringify(ch)}`);
for (const ch of no) assert.ok(!r.test(ch), `${String(re)} should not match ${JSON.stringify(ch)}`);
assert.equal((yes[0] + yes[1]).match(r)[0], yes[0], `${String(re)} should match one character at a time`);
}
test('HEX_DIGIT: one hex digit in either case', () => {
check(HEX_DIGIT, ['0', '9', 'a', 'f', 'A', 'F'], ['g', 'G', 'Z', '_', '[', '`', '-', ' ']);
});
test('SPECIAL: one of ] - ^ \\ and nothing else, also with the v flag', () => {
const yes = [']', '-', '^', '\\'];
const no = ['[', 'a', '_', '`', '.', '"'];
check(SPECIAL, yes, no);
let inV = null;
try {
inV = withV(SPECIAL);
} catch {
// v mode refused the class
}
assert.ok(inV, 'the class should also compile with the v flag: escape every character v mode reserves');
check(inV, yes, no);
});
test('CONSONANT: a to z without the five vowels, by set difference in v mode', () => {
assert.ok(CONSONANT.flags.includes('v'), 'use the v flag');
assert.ok(CONSONANT.source.includes('--'), 'take the vowels away with --');
check(CONSONANT, ['b', 'c', 'm', 'x', 'y', 'z'], ['a', 'e', 'i', 'o', 'u', 'B', '-', '[']);
});
test('DEVANAGARI: the block without its ten digits, by set difference in v mode', () => {
assert.ok(DEVANAGARI.flags.includes('v'), 'use the v flag');
assert.ok(DEVANAGARI.source.includes('--'), 'take the digits away with --');
check(DEVANAGARI, ['क', 'ि', '।', 'ॐ', '\u0900', '\u097F'], ['०', '९', '५', '9', 'a', '\u0A05']);
}); Python · Starter code · patterns.py
"""Each name holds the text of a pattern: a character class that matches exactly one character.
The tests compile each one with re and fail on any warning. Each one below has a common mistake.
"""
# One hexadecimal digit, 0-9 or a-f in either case.
HEX_DIGIT = r"[0-9A-f]"
# One of ] - ^ \ and nothing else.
SPECIAL = r"[]-^\\]"
# One lowercase consonant. re has no set operations: write the consonants as ranges.
CONSONANT = r"[[a-z]--[aeiou]]"
# One Devanagari character (U+0900 to U+097F) that is not a Devanagari digit (U+0966 to U+096F).
DEVANAGARI = r"[\u0900-\u097F]" The sample tests · test_patterns.py
import re
import warnings
from patterns import CONSONANT, DEVANAGARI, HEX_DIGIT, SPECIAL
def compiled(pattern):
"""Compiles the pattern with every warning turned into an error, so a FutureWarning fails the test."""
with warnings.catch_warnings():
warnings.simplefilter("error")
return re.compile(pattern)
def check(pattern, yes, no):
r = compiled(pattern)
for ch in yes:
assert r.search(ch), f"{pattern} should match {ch!r}"
for ch in no:
assert not r.search(ch), f"{pattern} should not match {ch!r}"
assert r.search(yes[0] + yes[1]).group() == yes[0], f"{pattern} should match one character at a time"
def test_hex_digit():
"""HEX_DIGIT: one hex digit in either case"""
check(HEX_DIGIT, ["0", "9", "a", "f", "A", "F"], ["g", "G", "Z", "_", "[", "`", "-", " "])
def test_special():
"""SPECIAL: one of ] - ^ \\ and nothing else"""
check(SPECIAL, ["]", "-", "^", "\\"], ["[", "a", "_", "`", ".", '"'])
def test_consonant():
"""CONSONANT: a to z without the five vowels, as ranges"""
check(CONSONANT, ["b", "c", "m", "x", "y", "z"], ["a", "e", "i", "o", "u", "B", "-", "["])
def test_devanagari():
"""DEVANAGARI: the block without its ten digits, as ranges"""
check(DEVANAGARI, ["क", "ि", "।", "ॐ", "\u0900", "\u097f"], ["०", "९", "५", "9", "a", "\u0a05"]) A hint
A range runs from one code point to another, so A-f covers far more than the letters you want: write the upper and lower case ranges separately. Inside a class, a backslash makes ], -, ^ and \ itself plain characters in every flavour, and v mode wants that backslash before the hyphen. In v mode, both sides of -- must be whole classes: [[0-9]--[5]] is the digits without 5.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the five concepts. The self-check starts where your answers are weakest, explains every answer the moment you choose it, and brings a missed question back once before it ends:
Practice that adapts to you
Practise this lesson
17 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does a negated class such as [^0-9] match, and how is it different from the dot?
A negated class matches exactly one character that is not in its set: [^0-9] takes any single character except the
ten ASCII digits, so it finds the hyphen in 4021-7. Two details separate it from the dot. The set is yours: the dot
leaves out only line terminators, while [^0-9] leaves out what you list and nothing else. And a class never treats
line terminators specially, so [^0-9] matches a newline that the dot, by default, does not. What it never matches
is nothing at all: it must take one character, so 7[^0-9] fails at the end of 4021-7, where no character follows
the 7. Go’s CompilePOSIX is an exception to the newline rule, because its classes leave the newline out.
The two questions below ask what happens when a class leaves the flavour it was written for: a POSIX name that moves to JavaScript, and a range that was meant to cover accented letters. Work out where each pattern goes wrong before you open its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A signup service in PHP checks usernames with preg_match('/^[[:alnum:]_-]+$/u', $name). Moved to Node.js as /^[[:alnum:]_-]+$/, it now rejects every username, with no error. Explain what JavaScript does with the pattern, and how you would port the check so both services accept the same names.
-
Fresher to mid
A registration form checks names with ^[A-Za-zÀ-ÿ]+$, meant as "letters, accents allowed". Testers report that A×B is accepted, and that Łukasz and नेहा are rejected. Explain all three results and propose a class that does what the form meant.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How many characters does a character class such as [0-9A-Fa-f] match at a time?
Exactly one, at one position: any one of its members. A six-digit hex colour such as C0FFEE therefore needs the class six times.
-
Why does the class [A-z] match 58 characters instead of 52 letters?
A range takes every code point from its first character to its last, and six symbols sit between Z and a. Give each block its own range, [A-Za-z]. A backwards range such as [a-Z] is an error.
-
What can the negated class [^0-9] match that the dot does not match by default?
A line break: a class never treats line terminators specially, so [^0-9] takes a newline. It still needs one character, so 7[^0-9] fails at the very end of the text.
-
How do you put ], - and ^ inside one class so that every flavour reads them as members?
Escape all three: [\]\-\^]. JavaScript ends a class at the first ] and reads [] as a class that never matches, while Python, PCRE2 and Go take a leading ] as a member.
-
Where can you use a POSIX name such as [[:alpha:]]?
In PCRE2 and Go. JavaScript turns it into a wrong class without an error, and Python builds the same wrong class with only a FutureWarning.
-
How is the set of letters a to z without the vowels written as one class?
[[a-z]--[aeiou]] in JavaScript with the v flag, (?[ ... ]) in PCRE2 from 10.45, && in Java and a subtraction -[...] in .NET. In Python, Go and PHP 8.5.11 you write the remaining ranges out by hand.
Key takeaways
- A character class matches exactly one character at one position, any one of its members; a caret right after the opening bracket negates it, and a negated class still takes one character, line breaks included, unlike the dot.
- A range covers every code point from its first character to its last, so
[A-z]takes 58 characters instead of 52, and a backwards range such as[a-Z]is an error in every flavour of this lesson. - JavaScript ends a class at the first
]and reads[]as a class that never matches, while Python, PCRE2 and Go take a first]as a member, so the portable class escapes all three special characters:[\]\-\^]. - POSIX names such as
[[:alpha:]]work in PCRE2 and Go; JavaScript turns them into a wrong class without any error, and Python into the same wrong class with a FutureWarning. - Set operations build a class from others, written
[[a-z]--[aeiou]]in JavaScript with the v flag,(?[ ... ])in PCRE2 from 10.45,&&in Java and-[...]in .NET, while Python, Go and PHP 8.5.11 need the ranges written out.
Terms in this lesson
- character class
- A part of a pattern between square brackets that matches exactly one character of the text: any one of the characters, ranges and classes listed inside it, such as [aeiou] for one vowel.
- character range
- Two characters joined by a hyphen inside a character class, such as a-z. It matches every character whose code point lies between the two, both included, so A-z also takes the six symbols between Z and a.
- class set operation
- Building a character class from other classes by difference, intersection or union, such as [[a-z]--[aeiou]] with JavaScript's v flag: a to z without the vowels. Each flavour that has set operations spells them its own way.
- negated class
- A character class that starts with a caret right after the opening bracket, such as [^0-9]. It matches exactly one character that is not in the set, line breaks included.
- POSIX bracket expression
- The POSIX name for a [...] class. It may hold class names such as [:alpha:] or [:digit:], as in [[:alpha:]]; PCRE2, Go and grep support those names, while JavaScript and Python read them as ordinary characters.
References
- ECMAScript 2026 Language Specification (ECMA-262, 17th edition): Patterns (Ecma International (TC39))
- ECMAScript 2026: CompileToCharSet and CharacterRange (Ecma International (TC39))
- RegExp v flag with set notation and properties of strings (TC39 proposal) (Ecma International (TC39))
- re: Regular expression operations (Python Software Foundation)
- pcre2pattern: Perl-compatible regular expressions (PCRE2 Project)
- pcre2test: a program for testing PCRE2 patterns (PCRE2 Project)
- News about PCRE2 releases: version 10.45 (PCRE2 Project)
- regexp/syntax: regular expression syntax (The Go Authors)
- RE2 syntax (Google)
- POSIX.1-2024 Base Definitions, 9.3.5 RE Bracket Expression (The Open Group and IEEE)
- Unicode Technical Standard #18: Unicode Regular Expressions, RL1.3 Subtraction and Intersection (Unicode Consortium)
- Pattern (Java SE 25 and JDK 25) (Oracle)
- Character classes in .NET regular expressions (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress