Regular Expressions Module 4 – Quantifiers: greedy, lazy and possessive
Repetition with *, +, ? and {m,n}
How *, +, ? and braces repeat the atom before them, where {,n} and large counts differ by flavour, and why x* returns empty matches.
What you will learn
- Predict which words *, +, ?, {n}, {n,} and {n,m} accept, and name the atom each quantifier repeats
- Group several characters with (?:...) when a quantifier must repeat all of them
- Distinguish six digits in a row from a six-digit number, and add the anchors or boundaries a check needs
- Compare how JavaScript, Python, PCRE2, PHP and Go read {,n}, spaces inside braces and counts above 1,000
- Choose + over * when collecting runs, and explain the empty matches that x* returns in each flavour
Before you start
What you’ll learn · Before you start
What you will learn
- Predict which words *, +, ?, {n}, {n,} and {n,m} accept, and name the atom each quantifier repeats
- Group several characters with (?:...) when a quantifier must repeat all of them
- Distinguish six digits in a row from a six-digit number, and add the anchors or boundaries a check needs
- Compare how JavaScript, Python, PCRE2, PHP and Go read {,n}, spaces inside braces and counts above 1,000
- Choose + over * when collecting runs, and explain the empty matches that x* returns in each flavour
Before you start
On this page
Search an address for \d{6} and it returns the PIN code 411001, and also 900001, the first six digits of the phone
number 9000012345. A quantifier: The part of a pattern that says how many times the atom before it may repeat: * for zero or more, + for one or more, ? for zero or one, or a count in braces such as {2,5}. such as {6} says how many times something repeats, but
not which part of the pattern repeats or where the run has to stop. The engines do not even agree on how to write one:
Python reads x{,3} as up to three x, JavaScript without a flag takes the five characters x{,3} as plain text, and
Go refuses any count above 1,000.
The sections below show what a quantifier attaches to and how a non-capturing group widens it, take the six forms on
words whose results you can read, and separate a count from a boundary. Then the brace forms run through JavaScript,
Python, PCRE2, PHP and Go, and the last section explains the empty matches that * returns. The exercise asks for
three formats with the right counts: a vehicle-plate shape, a phone extension and a laugh.
A quantifier repeats the atom just before it
Take the word banana and the pattern bana+. It looks like “bana, repeated”, but the + belongs to the last a alone:
the pattern asks for b, a and n once each, then one or more a. Every quantifier repeats exactly one
atom: The single item a quantifier repeats, written right before it: one character, the dot, an escape, a character class or a group. In ab+ the atom is b; in (?:ab)+ it is the group ab., the item written right before it. To repeat the pair na, wrap it in a
non-capturing group: Parentheses written (?:...), which make the part inside one atom, for a quantifier or an alternation, without saving the text it matched. Plain parentheses group the same way and also capture that text., (?:na), and the quantifier takes the whole group as its
atom. Step through both patterns, first on banana, then on banaaaa:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Four atoms, one quantifier. bana+ is four atoms in a row, b, a, n and a, and the + is attached to the last a alone. So the pattern asks for b, a and n once each, then one or more a.
- The + stops at the n. On banana, b, a and n match the first three letters, and a+ takes the a at position 3. The next character is n, not a, so the + has nothing more to take.
- The first match is bana. bana+ matches bana, four of the six letters. The second na is never repeated, because the + only ever looks for another a.
- Group the pair first. ba(?:na)+ puts n and a inside a non-capturing group. The group is one atom, so the + repeats the pair: b and a once, then na one or more times.
- The group repeats twice. On banana the group matches na at positions 2 and 3, then na again at 4 and 5. The text ends there, so the repetition stops after two rounds.
- The second match is banana. ba(?:na)+ matches the whole word, banana. The two patterns read the same letters, but the + repeats a different atom in each.
- On banaaaa the roles swap. On banaaaa, a+ takes all four a, so bana+ matches the whole word. The group takes na once, and the a at position 4 cannot start another na, so ba(?:na)+ stops at bana.
The two patterns trade results: bana+ takes all of banaaaa but only bana of banana, and ba(?:na)+ does the
opposite. The program below runs both, then sets a class beside the group, then puts one quantifier after each kind of
atom. Press Run, then Edit: add the word banananana to text and run it again, and the group repeats four times.
// A quantifier repeats the one atom right before it: in bana+ that is the last a, in ba(?:na)+ the group na.
const text = 'banana banaaaa';
for (const re of [/bana+/g, /ba(?:na)+/g]) {
console.log(`${String(re).padEnd(13)} ${JSON.stringify(text.match(re))}`);
}
// A class repeats one character from a set, in any mix; a group repeats its whole sequence.
const words = 'banaan baaaa banana';
for (const re of [/ba[na]+/g, /ba(?:na)+/g]) {
console.log(`${String(re).padEnd(13)} on ${JSON.stringify(words)} ${JSON.stringify(words.match(re))}`);
}
// An atom can be one character, the dot, an escape, a class or a group.
console.log('');
const atoms = [
[/colou?r/g, 'color colour colouur'],
[/.{3}/g, 'abcdefgh'],
[/\.{3}/g, 'wait... what.'],
[/[0-9]{4}/g, 'in 2026, 7 or 1999'],
[/(?:ab){2}/g, 'ab abab ababab'],
];
for (const [re, subject] of atoms) {
console.log(`${String(re).padEnd(13)} on ${JSON.stringify(subject).padEnd(24)} ${JSON.stringify(subject.match(re))}`);
}
// A quantifier cannot follow another quantifier: group the first one to repeat it.
console.log('');
for (const source of ['x{2}{3}', '(?:x{2}){3}']) {
try {
const whole = new RegExp(`^${source}$`);
console.log(`${source.padEnd(13)} matches "xxxxxx": ${whole.test('xxxxxx')}`);
} catch (e) {
console.log(`${source.padEnd(13)} ${e.name}`);
}
} Output
/bana+/g ["bana","banaaaa"]
/ba(?:na)+/g ["banana","bana"]
/ba[na]+/g on "banaan baaaa banana" ["banaan","baaaa","banana"]
/ba(?:na)+/g on "banaan baaaa banana" ["bana","banana"]
/colou?r/g on "color colour colouur" ["color","colour"]
/.{3}/g on "abcdefgh" ["abc","def"]
/\.{3}/g on "wait... what." ["..."]
/[0-9]{4}/g on "in 2026, 7 or 1999" ["2026","1999"]
/(?:ab){2}/g on "ab abab ababab" ["abab","abab"]
x{2}{3} SyntaxError
(?:x{2}){3} matches "xxxxxx": true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node atoms.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
The middle lines show the kinds of atom. colou?r accepts color and colour, since ? makes the one u optional, and
refuses colouur. .{3} takes any three characters at a time, while \.{3} takes three real dots. [0-9]{4} repeats
the class of Character classes, ranges and negation, so 2026 and 1999
both match although their digits differ. (?:ab){2} finds abab twice, once in abab and once at the start of ababab,
whose last ab is left over. The last two lines show what the grammar refuses, a count written right after another
count: x{2}{3} is a SyntaxError, and the group in (?:x{2}){3} says it properly, six x. A ? right after a
quantifier, as in x+?, is not a second count: every flavour here reads it as a change in how the first one matches,
and Python, PCRE2 and PHP read a + there the same way, while JavaScript and Go refuse it. The next two lessons of this
module explain both.
A class where a group was meant
Wrong: ba[na]+ to repeat the syllable na. A class is one character from a set, so [na]+ takes n and a in any
order, and the run shows ba[na]+ accepting banaan and baaaa as well as banana. Right: ba(?:na)+, which repeats
the two characters in their order.
Plain parentheses group too, and they also save the text they matched as a
group: A part of a pattern wrapped in parentheses whose matched text the engine remembers separately, for reuse or extraction. More in “What a regular expression is and where it runs”. That changes what some functions return: Python’s re.findall
hands back the saved text instead of the whole match.
"""Grouping before a quantifier in Python: findall returns what a capturing group saved, not the whole match."""
import re
text = "banana banaaaa"
for pattern in [r"ba(?:na)+", r"ba(na)+"]:
print(f"{pattern:11} findall {re.findall(pattern, text)}")
print()
for m in re.finditer(r"ba(na)+", text):
print(f"match {m.group(0)!r:9} group 1 {m.group(1)!r}") Output
ba(?:na)+ findall ['banana', 'bana'] ba(na)+ findall ['na', 'na'] match 'banana' group 1 'na' match 'bana' group 1 'na'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 atoms.py
With (?:na) the list holds banana and bana; with (na) it holds na twice, the text of the last repetition each match
saved. Capturing groups, their numbers and their names belong to a later module. When the only aim is to repeat
several characters, (?:...) is the group to write.
Key idea
A quantifier repeats the single atom right before it: one character, the dot, an escape, a class or a group. To
repeat several characters, make them one atom with (?:...).
Checkpoint
Check: What a quantifier repeats
Six quantifiers set a lowest and a highest count
Four words differ only in how many o they hold: gal has none, goal one, gooal two and gooooal four. Each quantifier allows a range of repetitions between two repetition bound: The lowest or the highest count a quantifier allows, such as 3 and 5 in {3,5}. A missing highest bound, as in {3,}, means no limit, though each engine caps how large a written count may be., a lowest and a highest count, and a missing highest bound means no limit. The six forms, on the o between g and al:
| Pattern | Lowest | Highest | Whole words it accepts |
|---|---|---|---|
go*al |
0 | no limit | gal, goal, gooal, gooooal |
go+al |
1 | no limit | goal, gooal, gooooal |
go?al |
0 | 1 | gal, goal |
go{2}al |
2 | 2 | gooal |
go{2,}al |
2 | no limit | gooal, gooooal |
go{1,2}al |
1 | 2 | goal, gooal |
The program checks every row of the table against the four words, then confirms that the one-character forms are
short for braces: * is {0,}, + is {1,} and ? is {0,1}. Press Edit, add goooal, with three o, to words,
and decide which patterns will accept it before you run it again:
// Six quantifiers on the o of go_al: which of four words does each one accept as a whole word?
const words = ['gal', 'goal', 'gooal', 'gooooal'];
console.log('words:', words.map((w) => `${w} (${w.length - 3} o)`).join(', '));
console.log('');
const forms = [
['go*al', 'zero or more'],
['go+al', 'one or more'],
['go?al', 'zero or one'],
['go{2}al', 'exactly two'],
['go{2,}al', 'two or more'],
['go{1,2}al', 'one or two'],
];
const accepts = (pattern) => words.filter((w) => new RegExp(`^${pattern}$`).test(w));
for (const [pattern, says] of forms) {
console.log(`${pattern.padEnd(10)} ${says.padEnd(13)} ${accepts(pattern).join(' ')}`);
}
// The three one-character quantifiers are short forms of braces.
console.log('');
for (const [short, braces] of [['go*al', 'go{0,}al'], ['go+al', 'go{1,}al'], ['go?al', 'go{0,1}al']]) {
const same = accepts(short).join() === accepts(braces).join();
console.log(`${braces.padEnd(10)} accepts the same words as ${short}: ${same}`);
} Output
words: gal (0 o), goal (1 o), gooal (2 o), gooooal (4 o)
go*al zero or more gal goal gooal gooooal
go+al one or more goal gooal gooooal
go?al zero or one gal goal
go{2}al exactly two gooal
go{2,}al two or more gooal gooooal
go{1,2}al one or two goal gooal
go{0,}al accepts the same words as go*al: true
go{1,}al accepts the same words as go+al: true
go{0,1}al accepts the same words as go?al: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node six.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
All the flavours of this track write the six forms the same way (ECMA-262 §22.2.1, Python’s re, pcre2pattern and Go’s
regexp/syntax). A bound counts repetitions of the atom, not characters of the text: [0-9]{4} is four characters,
and (?:ab){2} is also four, made of two repetitions of ab. Within its bounds a quantifier takes as many repetitions as
it can and gives some back only when the rest of the pattern fails, by the backtracking that
How an engine finds a match, step by step shows; Go’s documentation writes
“prefer more” beside *, + and the brace ranges, and “prefer one” beside ?. How a quantifier settles on its count, and the lazy forms that
prefer fewer, are the subject of the next lesson,
Greedy and lazy quantifiers.
Reading the star as anything at all
Wrong: go*al to accept every word that starts with go and ends with al, as a star does in a shell’s file pattern,
where it matches any string (POSIX §2.14.2). In a regex * repeats the atom before it, so go*al allows only o
between g and al, and refuses gopal. Right: go.*al when anything may stand there, or a class such as go[a-z]*al.
The table and the run agree on every word, and goooal, if you added it, lands in exactly the three rows whose range
holds 3: go*al, go+al and go{2,}al.
Key idea
Each quantifier is a range of counts of its atom: * is 0 or more, + 1 or more, ? 0 or 1, and braces write any
other range as {n}, {n,} or {n,m}.
Checkpoint
Check: The six quantifier forms
A count finds six digits inside longer numbers too
Each line below ends in a six-digit PIN code, and two of them hold a longer number as well: a ten-digit phone
number and a seven-digit order number. \d{6} reads like “a six-digit number”, but it asks only for six digits in a
row, starting at any position. The program below runs it, then the same count with \b on each side:
// \d{6} asks for six digits in a row at some position: it does not ask that the run of digits stops after six.
const lines = [
'Asha Rao, 14 MG Road, Pune 411001, phone 9000012345',
'Order 7300412 for Bengaluru 560001',
'Ravi Sen, 3 Park Street, Kolkata 700016',
];
for (const re of [/\d{6}/g, /\b\d{6}\b/g]) {
console.log(String(re));
for (const line of lines) console.log(` ${JSON.stringify(line.match(re))}`);
}
// Checking one field: test() succeeds as soon as six digits appear anywhere in it.
console.log('');
for (const field of ['411001', '4110012', 'PIN 411001']) {
console.log(`${JSON.stringify(field).padEnd(13)} /\\d{6}/ ${String(/\d{6}/.test(field)).padEnd(5)} /^\\d{6}$/ ${/^\d{6}$/.test(field)}`);
} Output
/\d{6}/g
["411001","900001"]
["730041","560001"]
["700016"]
/\b\d{6}\b/g
["411001"]
["560001"]
["700016"]
"411001" /\d{6}/ true /^\d{6}$/ true
"4110012" /\d{6}/ true /^\d{6}$/ false
"PIN 411001" /\d{6}/ true /^\d{6}$/ false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pin.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
\d{6} found 900001 inside 9000012345 and 730041 inside 7300412, numbers that nobody wrote. A count says how many
repetitions to take and nothing about what stands before or after them. \b, the word boundary of the previous
module, holds between a digit and a space or a comma but not between two digits, so \b\d{6}\b fails inside the
longer numbers and returns each PIN code alone. The last three lines test one form field at a time: /\d{6}/ is true
for 4110012 and for PIN 411001, because six digits stand somewhere in each, while the anchored ^\d{6}$ accepts only
a field that is six digits and nothing else.
Python counts the same way, but its \d takes the decimal digits of every script
(Shorthand classes for digits, words and spaces), so \d{6} also accepts ४११००१,
the same PIN code in Devanagari digits:
"""The same count in Python: \\d{6} takes six decimal digits of any script, [0-9]{6} only ASCII ones."""
import re
lines = [
"Asha Rao, 14 MG Road, Pune 411001, phone 9000012345",
"पुणे ४११००१", # Pune and its PIN code, written in Devanagari digits
]
for pattern in [r"\d{6}", r"\b\d{6}\b", r"\b[0-9]{6}\b"]:
print(f"{pattern:13}", [re.findall(pattern, line) for line in lines]) Output
\d{6} [['411001', '900001'], ['४११००१']]
\b\d{6}\b [['411001'], ['४११००१']]
\b[0-9]{6}\b [['411001'], []]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pin.py
Python’s \b treats those digits as word characters too, so \b\d{6}\b still returns ४११००१. Only the class
[0-9], which names the ten ASCII digits, leaves it out.
Checking a field with a count alone
Wrong: /\d{6}/.test(pin) to accept a PIN code field. It is true for 4110012 and for PIN 411001. Right:
/^\d{6}$/ in JavaScript, and re.fullmatch(r"[0-9]{6}", pin) in Python, where a bare \d would also let the
digits of other scripts through.
Both fixes keep the count as it was, {6}. What changes is the pattern around it: anchors or boundaries decide that the
run of digits starts and stops exactly there.
Key idea
A count says how many repetitions, never where they stop: when the whole run must have that length, put \b or
anchors around it.
Checkpoint
Check: A count is not a boundary
Braces that some flavours read as text
x{2,3} means two or three x in every flavour of this track. The forms around it travel less well: x{,3} leaves out
the lowest bound, x{1, 3} has a space, x{,} has no number at all, and each engine decides for itself whether such a
brace is a quantifier, an error or plain text. JavaScript alone gives two answers, one without a flag and one with u
or v. Press Run, then Edit and add a form of your own to forms, such as x{ 2}:
// Brace forms at the edge of the grammar, in JavaScript with no flag, with u and with v.
// "literal text" means the pattern matched its own characters; otherwise it says how many x it accepts.
const forms = ['x{2,3}', 'x{,3}', 'x{1, 3}', 'x{3-5}', 'x{,}', 'x{2,1}', 'x{1001}'];
function reading(source, flags) {
let whole;
try {
whole = new RegExp(`^(?:${source})$`, flags);
} catch (e) {
return e.name;
}
if (whole.test(source)) return 'literal text';
const counts = [];
for (let n = 0; n <= 1100; n++) if (whole.test('x'.repeat(n))) counts.push(n);
const [low, high] = [counts[0], counts.at(-1)];
return low === high ? `x ${low} times` : `x ${low} to ${high} times`;
}
console.log(`${'pattern'.padEnd(9)} ${'no flag'.padEnd(16)} ${'u'.padEnd(16)} v`);
for (const source of forms) {
const cells = ['', 'u', 'v'].map((flags) => reading(source, flags));
console.log(`${source.padEnd(9)} ${cells[0].padEnd(16)} ${cells[1].padEnd(16)} ${cells[2]}`);
} Output
pattern no flag u v
x{2,3} x 2 to 3 times x 2 to 3 times x 2 to 3 times
x{,3} literal text SyntaxError SyntaxError
x{1, 3} literal text SyntaxError SyntaxError
x{3-5} literal text SyntaxError SyntaxError
x{,} literal text SyntaxError SyntaxError
x{2,1} SyntaxError SyntaxError SyntaxError
x{1001} x 1001 times x 1001 times x 1001 times
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node braces.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 a flag, a brace that does not complete a quantifier is plain text: Annex B.1.2 of ECMA-262 lets { and }
stand for themselves outside Unicode mode, so x{,3} matches only the five characters x{,3} as text. With u or
v the stricter grammar of §22.2.1 applies, and the same forms are SyntaxErrors. x{2,1} fails in every mode,
because the first number may not exceed the second (§22.2.1.1), and x{1001} runs, since the grammar sets no upper
limit on a count. The other four engines ran the same forms:
Python 3.14.8 · braces/braces.py
"""Brace forms in Python's re: what each one means, and the largest count re accepts.
"literal text" means the pattern matched its own characters; otherwise the line says how many x it accepts, testing
every count from 0 to 1,100. A count too large to test that way is reported as compiled."""
import re
def reading(source: str) -> str:
try:
whole = re.compile(f"(?:{source})")
except re.error as e:
return f"error: {e.msg}"
except OverflowError as e:
return f"OverflowError: {e}"
if whole.fullmatch(source):
return "literal text"
counts = [n for n in range(1101) if whole.fullmatch("x" * n)]
if not counts:
return "compiles"
if counts[-1] == 1100:
return f"x {counts[0]} or more times"
low, high = counts[0], counts[-1]
return f"x {low} times" if low == high else f"x {low} to {high} times"
forms = ["x{2,3}", "x{,3}", "x{1, 3}", "x{3-5}", "x{,}", "x{2,1}", "x{1001}", "x{4294967294}", "x{4294967295}"]
for source in forms:
print(f"{source:14} {reading(source)}") Output
x{2,3} x 2 to 3 times
x{,3} x 0 to 3 times
x{1, 3} literal text
x{3-5} literal text
x{,} x 0 or more times
x{2,1} error: min repeat greater than max repeat
x{1001} x 1001 times
x{4294967294} compiles
x{4294967295} OverflowError: the repetition number is too large
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 braces.py
Go 1.27.1 · braces/braces.go
// Brace forms in Go's regexp package (RE2 syntax), and its limit of 1000 on every count, nested counts included.
// "literal text" means the pattern matched its own characters; otherwise the line says how many x it accepts.
package main
import (
"fmt"
"regexp"
"strings"
)
func reading(source string) string {
whole, err := regexp.Compile("^(?:" + source + ")$")
if err != nil {
return "error: " + strings.TrimPrefix(err.Error(), "error parsing regexp: ")
}
if whole.MatchString(source) {
return "literal text"
}
low, high := -1, -1
for n := 0; n <= 1100; n++ {
if whole.MatchString(strings.Repeat("x", n)) {
if low < 0 {
low = n
}
high = n
}
}
if low == high {
return fmt.Sprintf("x %d times", low)
}
return fmt.Sprintf("x %d to %d times", low, high)
}
func main() {
forms := []string{"x{2,3}", "x{,3}", "x{1, 3}", "x{3-5}", "x{,}", "x{2,1}", "x{1000}", "x{1001}", "(?:x{100}){10}", "(?:x{100}){11}"}
for _, source := range forms {
fmt.Printf("%-15s %s\n", source, reading(source))
}
} Output
x{2,3} x 2 to 3 times
x{,3} literal text
x{1, 3} literal text
x{3-5} literal text
x{,} literal text
x{2,1} error: invalid repeat count: `{2,1}`
x{1000} x 1000 times
x{1001} error: invalid repeat count: `{1001}`
(?:x{100}){10} x 1000 times
(?:x{100}){11} error: invalid repeat count: `{11}`
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run braces.go
PHP 8.5.11 · braces/braces.php
<?php
// Brace forms in PHP's preg functions, which run the PCRE2 library bundled with PHP.
// "literal text" means the pattern matched its own characters; otherwise the line says how many x it accepts.
function reading(string $source): string
{
$whole = '/^(?:' . $source . ')$/';
if (@preg_match($whole, '') === false) {
$message = error_get_last()['message'] ?? 'unknown';
error_clear_last();
return 'error: ' . preg_replace('/^preg_match\(\): Compilation failed: | at offset \d+$/', '', $message);
}
if (preg_match($whole, $source)) {
return 'literal text';
}
$counts = [];
for ($n = 0; $n <= 1100; $n++) {
if (preg_match($whole, str_repeat('x', $n))) {
$counts[] = $n;
}
}
if (!$counts) {
return 'compiles';
}
[$low, $high] = [$counts[0], end($counts)];
return $low === $high ? "x $low times" : "x $low to $high times";
}
printf("PCRE2 in this PHP: %s\n\n", explode(' ', PCRE_VERSION)[0]);
foreach (['x{2,3}', 'x{,3}', 'x{1, 3}', 'x{ 1 , 3 }', 'x{3-5}', 'x{,}', 'x{2,1}', 'x{1001}', 'x{65535}', 'x{65536}'] as $source) {
printf("%-11s %s\n", $source, reading($source));
} Output
PCRE2 in this PHP: 10.44
x{2,3} x 2 to 3 times
x{,3} x 0 to 3 times
x{1, 3} x 1 to 3 times
x{ 1 , 3 } x 1 to 3 times
x{3-5} literal text
x{,} literal text
x{2,1} error: numbers out of order in {} quantifier
x{1001} x 1001 times
x{65535} compiles
x{65536} error: number too big in {} quantifier
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php braces.php
PCRE2 10.49 · braces/braces_pcre2.py
"""Brace forms in PCRE2 10.49 through pcre2test.
Each pattern is anchored with ^ and $, so a subject line either matches as a whole (" 0:" and the match) or prints
"No match". A pattern that does not compile prints "Failed:" with PCRE2's error number and message, and pcre2test
skips its subject lines."""
from pcre2_session import run
run(r'''
/^x{,3}$/
xxx
x{,3}
/^x{1, 3}$/
xx
x{1, 3}
/^x{3-5}$/
xxx
x{3-5}
/^x{,}$/
xx
x{,}
/x{2,1}/
xx
/x{65535}/
x
/x{65536}/
x
''') Output
$ pcre2test -q patterns.txt (PCRE2 10.49)
/^x{,3}$/
xxx
0: xxx
x{,3}
No match
/^x{1, 3}$/
xx
0: xx
x{1, 3}
No match
/^x{3-5}$/
xxx
No match
x{3-5}
0: x{3-5}
/^x{,}$/
xx
No match
x{,}
0: x{,}
/x{2,1}/
Failed: error 104 at offset 5: numbers out of order in {} quantifier
here: x{2,1 |<--| }
xx
/x{65535}/
x
No match
/x{65536}/
Failed: error 105 at offset 7: number too big in {} quantifier
here: x{65536 |<--| }
x
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 braces_pcre2.py
braces/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] Python reads a missing lowest bound as 0 and a missing highest bound as no limit, as its documentation says, so
x{,3} is zero to three x and x{,} is the same as x*. PCRE2 10.49, and PHP 8.5.11 with the PCRE2 10.44 it
bundles, read x{,3} as x{0,3} and allow spaces inside the braces, yet keep the {,} of x{,} as three characters of text
(pcre2pattern, Repetition). Go reads all three as text. Put together, in the
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” of this track:
| Form | A quantifier in | Plain text or an error in |
|---|---|---|
x{,3} |
Python; PCRE2 10.43 or later; PHP 8.4 or later | Go: text; JavaScript: text, or a SyntaxError with u or v |
x{1, 3} |
PCRE2 10.43 or later; PHP 8.4 or later | Python and Go: text; JavaScript: text, or a SyntaxError with u or v |
x{,} |
Python, as x{0,} |
PCRE2, PHP and Go: text; JavaScript: text, or a SyntaxError with u or v |
x{2,1} |
none | every engine: an error |
Since PCRE2 10.43 and PHP 8.4
Perl 5.34.0 began to read {,3} as {0,3}, and PCRE2 followed in 10.43, which also allowed spaces after {, before
} and around the comma (its ChangeLog, items 29 and 30 of 10.43). PHP 8.4 bundles PCRE2 10.44, so in PHP a pattern
such as \d{,8} changed meaning: before 8.4 its braces were plain text.
Each engine also caps the count you may write, and the caps differ widely. The runs above reached each one:
| Engine | Largest count it accepts |
|---|---|
| JavaScript | no limit in the grammar: x{1001} runs |
Python re |
4,294,967,294; one more is an OverflowError |
| PCRE2 and PHP | 65,535; one more fails with “number too big” |
Go regexp |
1,000, also for a count that nesting multiplies |
Go’s documentation says its counting forms reject any form that creates a minimum or maximum count above 1,000
(regexp/syntax), and a nested count creates one: (?:x{100}){10} is accepted, a thousand x, while (?:x{100}){11}
is refused at the outer count.
Key idea
Write both bounds and no spaces, x{0,3} rather than x{,3} or x{0, 3}, and keep counts at 1,000 or less where
Go may run the pattern: that form means the same in every flavour.
Checkpoint
Check: Brace forms and limits by flavour
A quantifier that can match nothing returns empty matches
x* allows zero repetitions, so it succeeds even at a position where no x stands, by matching nothing. Such an
empty match: A match of length zero: the pattern succeeds at a position without taking any character, as x* does where no x stands. Global searches, splits and replacements report such matches, each engine by its own rules. is a real match whose length is zero, and a global search in JavaScript over
axxb finds three of them besides xx. Step through the search one position at a time:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Four characters, five positions. axxb has four characters and five positions, 0 to 4: one before each character and one at the end. A global search tries them from the left, as every search does.
- Zero x at position 0. At position 0 the character is a. x* may repeat x zero times, so it succeeds there without taking a character: match 1 is the empty string at position 0.
- The search moves one on. A search that began at position 0 again would find the same empty match for ever. So after an empty match the next search starts one position on: JavaScript adds one to lastIndex.
- x* takes both x. At position 1, x* takes both x characters, as many as it can. Match 2 is xx, which covers positions 1 and 2 and ends at position 3.
- No search starts at 2. The next search starts where match 2 ended, at position 3. Position 2 lies inside xx, so no search ever starts there.
- An empty match after xx. At position 3 the character is b, so x* matches empty again: match 3. JavaScript, Python, PHP and PCRE2 keep it. Go ignores an empty match that touches the match before it, so Go returns three matches.
- The end of the text. Position 4 is the end of the text, and x* matches empty there too: match 4. Position 5 would lie past the end, so the search stops with four matches: empty, xx, empty, empty.
- With x+ only xx is left. x+ needs at least one x, so it can never match empty. Its only match in axxb is xx at position 1, which is why + is the quantifier for collecting runs.
The JavaScript run prints the four matches with their positions, then what split and replace make of them, and then
the same search with x+. Press Edit, change 'axxb' to 'xaxx', and work out the matches before you run it again:
// x* may repeat x zero times, so it also matches the empty string at every position where no x starts.
const text = 'axxb';
for (const source of ['x*', 'x+']) {
const global = new RegExp(source, 'g');
const found = [...text.matchAll(global)].map((m) => `${m.index}:${JSON.stringify(m[0])}`);
console.log(`/${source}/ on "${text}"`);
console.log(` matchAll ${found.join(' ')}`);
console.log(` split ${JSON.stringify(text.split(new RegExp(source)))}`);
console.log(` replace ${JSON.stringify(text.replace(global, '-'))}`);
}
// Collecting the numbers of a line: \d* also returns an empty string at every position without a digit.
const line = 'Order 42, qty 7';
const star = line.match(/\d*/g);
console.log('');
console.log(`\\d* on "${line}": ${star.length} strings, ${star.filter((s) => s === '').length} of them empty`);
console.log(`\\d+ on "${line}": ${JSON.stringify(line.match(/\d+/g))}`);
// A whole-field check written with * also accepts a field with nothing in it.
console.log('');
for (const re of [/^[0-9]*$/, /^[0-9]+$/]) {
console.log(`${String(re).padEnd(10)} accepts the empty string: ${re.test('')}`);
} Output
/x*/ on "axxb" matchAll 0:"" 1:"xx" 3:"" 4:"" split ["a","b"] replace "-a--b-" /x+/ on "axxb" matchAll 1:"xx" split ["a","b"] replace "a-b" \d* on "Order 42, qty 7": 15 strings, 13 of them empty \d+ on "Order 42, qty 7": ["42","7"] /^[0-9]*$/ accepts the empty string: true /^[0-9]+$/ accepts the empty string: false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node empty.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
replace writes a dash at each of the four matches, so -a--b- has two dashes between a and b: one for xx and one for
the empty match after it. split gives ["a","b"], because JavaScript skips an empty match at the place where the
last piece ended (ECMA-262 §22.2.6.14). After an empty match a global search moves one position on (§22.2.6.8), so it
never finds the same empty match twice. The other engines agree on most of this, but not all of it:
Python 3.14.8 · empty/empty.py
"""Empty matches in Python's re: finditer, findall, split and sub with x* and x+."""
import re
text = "axxb"
for pattern in [r"x*", r"x+"]:
found = " ".join(f"{m.start()}:{m.group()!r}" for m in re.finditer(pattern, text))
print(f"{pattern} on {text!r}")
print(f" finditer {found}")
print(f" findall {re.findall(pattern, text)}")
print(f" split {re.split(pattern, text)}")
print(f" sub {re.sub(pattern, '-', text)!r}")
line = "Order 42, qty 7"
star = re.findall(r"\d*", line)
print()
print(f"\\d* on {line!r}: {len(star)} strings, {star.count('')} of them empty")
print(f"\\d+ on {line!r}: {re.findall(r'\d+', line)}") Output
x* on 'axxb' finditer 0:'' 1:'xx' 3:'' 4:'' findall ['', 'xx', '', ''] split ['', 'a', '', 'b', ''] sub '-a--b-' x+ on 'axxb' finditer 1:'xx' findall ['xx'] split ['a', 'b'] sub 'a-b' \d* on 'Order 42, qty 7': 15 strings, 13 of them empty \d+ on 'Order 42, qty 7': ['42', '7']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 empty.py
Go 1.27.1 · empty/empty.go
// Empty matches in Go's regexp package: the FindAll functions ignore an empty match that abuts the match before it.
package main
import (
"fmt"
"regexp"
"strings"
)
func main() {
text := "axxb"
for _, source := range []string{`x*`, `x+`} {
re := regexp.MustCompile(source)
found := []string{}
for _, m := range re.FindAllStringIndex(text, -1) {
found = append(found, fmt.Sprintf("%d:%q", m[0], text[m[0]:m[1]]))
}
fmt.Printf("%s on %q\n", source, text)
fmt.Printf(" FindAllStringIndex %s\n", strings.Join(found, " "))
fmt.Printf(" FindAllString %q\n", re.FindAllString(text, -1))
fmt.Printf(" Split %q\n", re.Split(text, -1))
fmt.Printf(" ReplaceAllString %q\n", re.ReplaceAllString(text, "-"))
}
line := "Order 42, qty 7"
star := regexp.MustCompile(`\d*`).FindAllString(line, -1)
empty := 0
for _, s := range star {
if s == "" {
empty++
}
}
fmt.Println()
fmt.Printf("\\d* on %q: %d strings, %d of them empty\n", line, len(star), empty)
fmt.Printf("\\d+ on %q: %q\n", line, regexp.MustCompile(`\d+`).FindAllString(line, -1))
} Output
x* on "axxb" FindAllStringIndex 0:"" 1:"xx" 4:"" FindAllString ["" "xx" ""] Split ["a" "b"] ReplaceAllString "-a-b-" x+ on "axxb" FindAllStringIndex 1:"xx" FindAllString ["xx"] Split ["a" "b"] ReplaceAllString "a-b" \d* on "Order 42, qty 7": 13 strings, 11 of them empty \d+ on "Order 42, qty 7": ["42" "7"]
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run empty.go
PHP 8.5.11 · empty/empty.php
<?php
// Empty matches in PHP's preg functions: preg_match_all, preg_split and preg_replace with x* and x+.
$text = 'axxb';
foreach (['/x*/', '/x+/'] as $pattern) {
preg_match_all($pattern, $text, $found, PREG_OFFSET_CAPTURE);
$at = array_map(fn ($m) => $m[1] . ':' . json_encode($m[0]), $found[0]);
printf("%s on \"%s\"\n", $pattern, $text);
printf(" preg_match_all %s\n", implode(' ', $at));
printf(" preg_split %s\n", json_encode(preg_split($pattern, $text)));
printf(" preg_replace %s\n", json_encode(preg_replace($pattern, '-', $text)));
}
$line = 'Order 42, qty 7';
preg_match_all('/\d*/', $line, $star);
preg_match_all('/\d+/', $line, $plus);
$empty = count(array_filter($star[0], fn ($s) => $s === ''));
printf("\n\\d* on \"%s\": %d strings, %d of them empty\n", $line, count($star[0]), $empty);
printf("\\d+ on \"%s\": %s\n", $line, json_encode($plus[0])); Output
/x*/ on "axxb" preg_match_all 0:"" 1:"xx" 3:"" 4:"" preg_split ["","a","","b",""] preg_replace "-a--b-" /x+/ on "axxb" preg_match_all 1:"xx" preg_split ["a","b"] preg_replace "a-b" \d* on "Order 42, qty 7": 15 strings, 13 of them empty \d+ on "Order 42, qty 7": ["42","7"]
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php empty.php
PCRE2 10.49 · empty/empty_pcre2.py
"""Empty matches in PCRE2 10.49 through pcre2test.
The g modifier finds every match, as a global search does in Perl: after an empty match, pcre2test searches again from
the same point with the PCRE2_NOTEMPTY_ATSTART option, which refuses a second empty match there. aftertext prints the
rest of the subject after each match on a " 0+" line, which shows where the match ended."""
from pcre2_session import run
run(r'''
/x*/g,aftertext
axxb
/x+/g,aftertext
axxb
''') Output
$ pcre2test -q patterns.txt (PCRE2 10.49)
/x*/g,aftertext
axxb
0:
0+ axxb
0: xx
0+ b
0:
0+ b
0:
0+
/x+/g,aftertext
axxb
0: xx
0+ b
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 empty_pcre2.py
| Engine | Matches of x* |
split |
Replace with - |
|---|---|---|---|
| JavaScript | 4: at 0, 1, 3, 4 | a, b |
-a--b- |
| Python | 4: at 0, 1, 3, 4 | '', a, '', b, '' |
-a--b- |
| PHP | 4: at 0, 1, 3, 4 | '', a, '', b, '' |
-a--b- |
| Go | 3: at 0, 1, 4 | a, b |
-a-b- |
Go ignores an empty match that touches the match before it (the regexp documentation), so the empty match at
position 3, right after xx, is not reported, and its replacement has one dash between a and b. Python’s and PHP’s
split cut at every match, empty ones included, so their lists start and end with an empty string. pcre2test finds
the same four matches as PHP: after an empty match it searches again at the same point for one that is not empty,
which is what Perl does (pcre2test, Finding all matches), and its 0+ lines show where each match ended.
The difference matters as soon as you collect. On Order 42, qty 7, \d* returns 15 strings, 13 of them empty, in
JavaScript, Python and PHP, and 13 strings with 11 empty in Go; \d+ returns 42 and 7 everywhere. The same zero lets a
check through: the last lines of the JavaScript run show ^[0-9]*$ accepting the empty string, which ^[0-9]+$
refuses.
Collecting with a star
Wrong: line.match(/\d*/g) to collect the numbers of a line; it returns an empty string at every position without a
digit. Right: line.match(/\d+/g), which can only match runs of at least one digit.
A quantifier whose lowest bound is 0 can always match nothing, and ? is such a quantifier too, so colou?r never
matches empty but u? alone would.
Key idea
Use + when you collect, split or replace runs, and keep * and ? for places where nothing at all is a valid
answer: wherever a pattern can match nothing, a global search returns empty matches.
Checkpoint
Check: Empty matches of * and ?
Practise: three formats with the right counts
The exercise asks for three patterns, each for a format the exercise itself defines, not the rules of any authority: a vehicle-plate shape with optional spaces, a phone extension of three to five digits, and a laugh made of two or more ha. Each starter pattern carries one typical slip, and the page can run the sample tests on your answer, written in JavaScript or in Python.
Exercise · Easy · JavaScript, Python
Three formats with the right counts
Write three patterns, one for each format below. The formats are defined by this exercise, not by the rules of any registration authority or telephone system. Solve them in JavaScript (patterns.mjs, a regular expression literal for each name) or in Python (patterns.py, the pattern text for each name). The JavaScript tests call test() on one whole field at a time, so each pattern must say where the field starts and ends; the Python tests call re.fullmatch(), which already asks for the whole field.
PLATE: a simplified vehicle-plate shape. Two capital lettersAtoZ, then two digits, then one or two capital letters, then four digits, such asMH 12 AB 1234orKA05M0001. Between two neighbouring groups there may be one space or none: no other character, and never two spaces.EXTENSION: a phone extension of three to five digits0to9, such as123or89012, and nothing else.LAUGH: two or morehain a row, in lower case, such ashahaorhahaha. A singlehais not a laugh here, and neither ishaa.
The starter code holds one common slip in every pattern. The sample tests list what each pattern must accept and what it must refuse.
JavaScript · Starter code · patterns.mjs
// Each name holds a regular expression. The tests run it with test() on one whole field at a time.
// Every pattern below has one common slip: the sample tests show which inputs it gets wrong.
/** An exercise-defined plate shape: two letters, two digits, one or two letters, four digits; one space or none between groups. */
export const PLATE = /^[A-Z]{2}\s*[0-9]{2}\s*[A-Z]*\s*[0-9]{4}$/;
/** A phone extension: three to five digits 0 to 9, and nothing else. */
export const EXTENSION = /\d{3,5}/;
/** A laugh: two or more ha in a row, in lower case. */
export const LAUGH = /^ha+$/; The sample tests · patterns.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { EXTENSION, LAUGH, PLATE } 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, ''));
function check(name, re, accept, refuse) {
const r = fresh(re);
for (const s of accept) assert.ok(r.test(s), `${name} should accept ${JSON.stringify(s)}`);
for (const s of refuse) assert.ok(!r.test(s), `${name} should refuse ${JSON.stringify(s)}`);
}
test('PLATE: two letters, two digits, one or two letters, four digits', () => {
check('PLATE', PLATE, ['MH12AB1234', 'KA05M0001', 'DL01C7777'], [
'MH12AB123', 'MH12AB12345', 'MH12ABC1234', 'M12AB1234', 'MH1AB1234', 'MH121234', 'mh12ab1234', '',
]);
});
test('PLATE: one space or none between two groups, and nothing else', () => {
check('PLATE', PLATE, ['MH 12 AB 1234', 'KA 05 M 0001', 'MH12 AB1234', 'TN 09C 4321'], [
'MH 12 AB 1234', 'MH 12 AB 1234', 'MH-12-AB-1234', 'MH\t12 AB 1234', ' MH 12 AB 1234', 'MH 12 AB 1234 ', 'MH 1 2 AB 1234',
]);
});
test('EXTENSION: three to five digits 0 to 9, and nothing else', () => {
check('EXTENSION', EXTENSION, ['123', '4567', '89012', '000'], ['12', '123456', '12a4', '', ' 123', 'ext 123', '१२३']);
});
test('LAUGH: two or more ha in a row, in lower case', () => {
check('LAUGH', LAUGH, ['haha', 'hahaha', 'hahahahaha'], ['ha', 'haa', 'hah', 'hahah', 'HAHA', 'ahah', 'haha!', '']);
}); Python · Starter code · patterns.py
"""Each name holds the text of a pattern. The tests compile it with re and call fullmatch() on one whole field at a
time, so a pattern needs no anchors here.
Every pattern below has one common slip: the sample tests show which inputs it gets wrong.
"""
# An exercise-defined plate shape: two letters, two digits, one or two letters, four digits; one space or none between
# groups.
PLATE = r"[A-Z]{2}\s*[0-9]{2}\s*[A-Z]*\s*[0-9]{4}"
# A phone extension: three to five digits 0 to 9, and nothing else.
EXTENSION = r"\d{3,5}"
# A laugh: two or more ha in a row, in lower case.
LAUGH = r"ha+" The sample tests · test_patterns.py
import re
import warnings
from patterns import EXTENSION, LAUGH, PLATE
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(name, pattern, accept, refuse):
r = compiled(pattern)
for s in accept:
assert r.fullmatch(s), f"{name} should accept {s!r}"
for s in refuse:
assert not r.fullmatch(s), f"{name} should refuse {s!r}"
def test_plate_groups():
"""PLATE: two letters, two digits, one or two letters, four digits"""
check("PLATE", PLATE, ["MH12AB1234", "KA05M0001", "DL01C7777"],
["MH12AB123", "MH12AB12345", "MH12ABC1234", "M12AB1234", "MH1AB1234", "MH121234", "mh12ab1234", ""])
def test_plate_spaces():
"""PLATE: one space or none between two groups, and nothing else"""
check("PLATE", PLATE, ["MH 12 AB 1234", "KA 05 M 0001", "MH12 AB1234", "TN 09C 4321"],
["MH 12 AB 1234", "MH 12 AB 1234", "MH-12-AB-1234", "MH\t12 AB 1234", " MH 12 AB 1234", "MH 12 AB 1234 ",
"MH 1 2 AB 1234"])
def test_extension():
"""EXTENSION: three to five digits 0 to 9, and nothing else"""
check("EXTENSION", EXTENSION, ["123", "4567", "89012", "000"], ["12", "123456", "12a4", "", " 123", "ext 123", "१२३"])
def test_laugh():
"""LAUGH: two or more ha in a row, in lower case"""
check("LAUGH", LAUGH, ["haha", "hahaha", "hahahahaha"], ["ha", "haa", "hah", "hahah", "HAHA", "ahah", "haha!", ""]) A hint
For each group of characters, decide its lowest and its highest count, then write exactly that range: something that may be missing or appear once is ?, and three to five of something is {3,5}. \s* takes tabs and runs of spaces, while a single space followed by ? takes one space or none. A quantifier repeats only the atom right before it, so a syllable that repeats needs a group. In Python, \d also takes the digits of other scripts.
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 check the five ideas with the self-check below. It starts with the idea you miss most and explains each choice the moment you pick it:
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 the plus repeat in ab+ and in (?:ab)+, and what do *, + and ? each allow?
A quantifier repeats the one atom written right before it. In ab+ that atom is b, so the pattern matches an a and
then one or more b: ab, abb, abbb. In (?:ab)+ the non-capturing group makes ab a single atom, so it matches ab, abab,
ababab. * allows zero or more repetitions, + one or more and ? zero or one; they are short for {0,}, {1,} and
{0,1}, and braces state any other range, such as {2,4}. Two points show real understanding. A count is not a
boundary: \d{6} also finds six digits inside a longer number unless anchors or \b surround it. And * and ? can
match the empty string, so a global search with them returns empty matches, which is why + is the safer choice for
collecting runs.
Two longer questions follow from the brace and limit sections: one moves a pattern with a missing lower bound between engines, the other meets Go’s cap of 1,000 in rules that people other than the developers write. Sketch an answer to each, then compare it with its page:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An invoice field is checked with ^INV-\d{,6}$ in a Python service, and its tests succeed. The browser copy of the rule refuses every invoice number people type, and after a PHP server moved from 8.3 to 8.4 it began to accept INV- with no digits. Explain all three, and write one rule that means the same everywhere.
-
Senior to staff
Administrators of a log-search product write their own match rules, which a Go service compiles. Two rules that worked in the Python prototype now fail with invalid repeat count: [^,]{0,2000} for a field of up to 2,000 characters, and (?:[0-9]{3}-){400}. Why does Go refuse them, and how should the product let administrators express such rules?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which part of a pattern does a quantifier repeat?
Only the one atom right before it: a character, the dot, an escape, a class or a group. bana+ repeats the last a, while ba(?:na)+ repeats the pair na.
-
Which range of counts does each of *, +, ? and the brace forms allow?
* allows 0 or more, + 1 or more and ? 0 or 1. Braces write any other range: {n} exactly n, {n,} at least n, {n,m} from n to m.
-
Why does \d{6} return 900001 when it searches a line holding the phone number 9000012345?
A count fixes how many repetitions, not where the run stops, so six digits inside a longer number match too. Use \b\d{6}\b to search, or anchors such as ^\d{6}$ to check a field.
-
Why is ba[na]+ the wrong way to repeat the syllable na?
A class matches one character from a set, so [na]+ takes n and a in any order and also accepts banaan and baaaa. ba(?:na)+ repeats the two characters in their order.
-
Does go*al match gopal?
No. In a regex the star repeats the atom before it, so go*al allows only more o's between g and al. A shell's star means any string; in a regex write go.*al, or go[a-z]*al.
-
Which way of writing a brace count means the same in every flavour of the track?
Both bounds and no spaces, such as x{0,3}, with counts of at most 1,000. x{,3} is plain text in Go and in JavaScript without a flag, and Go refuses any count above 1,000.
-
Why does line.match(/\d*/g) return empty strings among the numbers?
\d* can match nothing, so a global search reports an empty match at each position without a digit. Collect runs with \d+, which needs at least one digit.
Key takeaways
- A quantifier repeats only the atom right before it, so
bana+repeats the last a whileba(?:na)+repeats the pair na; a non-capturing group is how several characters become one atom. - The six forms are ranges of counts:
*is 0 or more,+1 or more,?0 or 1, and{n},{n,}and{n,m}write any range out, the same way in every flavour of the track. - A count is not a boundary:
\d{6}finds 900001 inside 9000012345, so a field check needs anchors and a search needs\baround the count. - The shortcuts
x{,3}andx{1, 3}are quantifiers in PCRE2 10.43 and later, andx{,3}also in Python, but plain text in Go and in JavaScript without a flag; the largest count is 1,000 in Go, 65,535 in PCRE2 and PHP and far higher in Python, whilex{0,3}means the same everywhere. - A quantifier that can match nothing returns empty matches: JavaScript, Python and PHP also report the one right
after a non-empty match, Go leaves it out, and collecting with
+avoids them in every engine.
Terms in this lesson
- atom
- The single item a quantifier repeats, written right before it: one character, the dot, an escape, a character class or a group. In ab+ the atom is b; in (?:ab)+ it is the group ab.
- empty match
- A match of length zero: the pattern succeeds at a position without taking any character, as x* does where no x stands. Global searches, splits and replacements report such matches, each engine by its own rules.
- non-capturing group
- Parentheses written (?:...), which make the part inside one atom, for a quantifier or an alternation, without saving the text it matched. Plain parentheses group the same way and also capture that text.
- quantifier
- The part of a pattern that says how many times the atom before it may repeat: * for zero or more, + for one or more, ? for zero or one, or a count in braces such as {2,5}.
- repetition bound
- The lowest or the highest count a quantifier allows, such as 3 and 5 in {3,5}. A missing highest bound, as in {3,}, means no limit, though each engine caps how large a written count may be.
References
- ECMAScript 2026 (ECMA-262, 17th edition): Patterns, the Quantifier grammar (§22.2.1) (Ecma International (TC39))
- ECMAScript 2026: Static Semantics, Early Errors of patterns (§22.2.1.1) (Ecma International (TC39))
- ECMAScript 2026: RepeatMatcher (§22.2.2.3.1) (Ecma International (TC39))
- ECMAScript 2026: Annex B.1.2, Regular Expressions Patterns (braces outside Unicode mode) (Ecma International (TC39))
- ECMAScript 2026: RegExp.prototype[%Symbol.match%] (§22.2.6.8) (Ecma International (TC39))
- ECMAScript 2026: RegExp.prototype[%Symbol.split%] (§22.2.6.14) (Ecma International (TC39))
- re: Regular expression syntax (Python Software Foundation)
- re: re.split(), re.findall() and re.sub(), empty matches (Python Software Foundation)
- pcre2pattern: Repetition (PCRE2 Project)
- pcre2test: Finding all matches in a string (PCRE2 Project)
- PCRE2 ChangeLog, Version 10.43 (items 29 and 30) (PCRE2 Project)
- PHP Manual: Repetition (The PHP Group)
- regexp/syntax: regular expression syntax (repetition and its limit) (The Go Authors)
- regexp: the All functions and empty matches (The Go Authors)
- RE2 syntax (Google)
- POSIX.1-2024, Shell Command Language: Patterns Matching Multiple Characters (§2.14.2) (The Open Group and IEEE)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress