Majid Al-RaimiRegular expressions in practice

ICS 582Lecture 02Part 09

Regular expressions in practice

Escaping, precedence and groups, substitutions with backreferences, lookarounds, the cheat sheets, the GPT-2 pre-tokenizer regex, and the engineering habits that keep patterns correct and fast.

Concepts
6
Slides
66-78
Reading
36 min
Understood
0/6 concepts

Why this part matters

Part 08 gave you the alphabet of regular expressions. This part is about using it under real conditions: patterns that must match a literal period, patterns that must reorder a date, patterns that must enforce three rules at once, and one pattern that quietly decides how every GPT-2 style tokenizer sees text.

Most BPE tokenizers you will train or inspect, GPT-2's included, start with a regex pre-tokenizer, every cleaning script for an Arabic corpus is a chain of substitutions, and the exam asks you to work precedence, substitution and lookahead by hand. The cheat sheets on slides 73 to 75 are here as reference tables you can come back to, and the workbench lets you test every claim in the browser.

By the end you can

  1. Escape metacharacters and predict what (cat|dog)s versus cat|dogs matches using the precedence ladder.
  2. Write a capture-group substitution that reorders a date and a backreference that finds a doubled word.
  3. Write a lookahead-based password check and explain why lookahead consumes nothing.
  4. Explain each alternative of the GPT-2 pre-tokenizer on a given string and why it needs \p{L}.
  5. Recognize catastrophic backtracking, give an example, and name two ways to make a pattern Unicode-aware.

Suppose you want to find every mention of Dr. in a corpus. The obvious pattern Dr. also matches Drs and Dry, because a period in a pattern means any character. Written as Dr\., it matches only the period. That is the whole idea of escaping: fourteen characters, . ^ $ * + ? ( ) [ ] { } | \, mean something to the engine, and a backslash returns them to their plain selves.

Escapes from SLP3 Figure 2.14, plus the two that run the other way

\*
an asterisk, as in K*A*P*L*A*N
\.
a period, as in Dr. Livingston, I presume
\?
a question mark, as in Why don't they come and lend a hand?
\n
a newline (the backslash gives a plain letter a special meaning)
\t
a tab (same direction: plain letter, special meaning)

Notice that the backslash works in both directions. \. turns a special character into a literal one, while \n and \t turn plain letters into a newline and a tab. When a pattern needs a literal backslash it doubles: \\. In Python, write patterns as raw strings, r'...', so the string literal does not eat the backslashes before the regex engine sees them (Python re documentation).

The precedence ladder

Escaping decides what a character means. Precedence decides how the operators bind, and it is where most wrong answers come from. Take the pattern cat|dogs and run it on the string cats dogs cat dog. Students expect cats and dogs. The engine returns cat (inside cats), dogs, and cat again, because the disjunction operator | has the lowest precedence of all: it splits the entire expression into cat on one side and dogs on the other. Parentheses fence the alternation in, so (cat|dog)s reads as cat or dog, then s, and returns cats and dogs.

Where the vertical bar cuts depends on the parentheses. Same letters, two different sets of matches.
PatternReads asMatches
cat|dogscat, or dogscat, dogs, cat
(cat|dog)scat or dog, then scats, dogs
Two patterns on the string cats dogs cat dog

SLP3 (section 2.6.4) gives the full ladder. Parentheses bind tightest, then the counters (the quantifiers), then plain sequences and anchors, and last of all the disjunction. This is why the* matches theeeee and not thethe: the star applies to the single letter e, not to the whole sequence. It is why the|any matches the or any but never thany. And it is why guppy|ies fails to match guppies: it means guppy or ies, so the fix is gupp(y|ies).

Operator precedence, highest first (SLP3, section 2.6.4)

1 Parenthesis
( )
2 Counters
* + ? {}
3 Sequences and anchors
the, ^my end$
4 Disjunction
|

The same ladder explains a subtler example from SLP3. Column [0-9]+ * matches one column label such as Column 1 followed by any number of spaces, because the star applies only to the final space. To repeat over a whole row of labels, Column 1 Column 2 Column 3, the sequence has to be fenced first. SLP3 writes (Column [0-9]+ +)*, which needs a space after every label, so on this row it stops before Column 3; (Column [0-9]+ *)* allows zero trailing spaces and takes the whole row.

Grouping without remembering

Parentheses do two jobs at once. They set the scope of an operator, and they create a capture group that stores whatever the group matched. Sometimes you want only the first job. Thenon-capturing form (?:...) groups without allocating a register, which keeps the numbering of the groups you do care about stable. SLP3 (2.17) uses it to skip fourteen dates and capture only the fifteenth: (?:\d\d/\d\d/\d\d\d\d\s+){14}(\d\d/\d\d/\d\d\d\d). The repeated dates are grouped so that the counter {14} applies to the whole date, and only the last date lands in group 1.

Recall

Why does guppy|ies fail to match guppies, and what is the fix?

Sequences bind tighter than |, so the pattern means the whole word guppy or the whole string ies. Fence the alternation: gupp(y|ies).

Quick check

Which whole word does the pattern cat|dogs match but (cat|dog)s never matches?

Real patterns are assembled from small pieces, tested, and grown. Start with an email address. It has a local part, an at sign and a domain, so the very simplified pattern on slide 69 is three pieces glued together: [A-Za-z0-9._]+@[A-Za-z0-9._]+. A character class of letters, digits, dots and underscores, repeated at least once, on both sides of the @. The same compose-then-test loop gives a course-code ID pattern in one line: two to four uppercase letters, an optional space, three digits, [A-Z]{2,4}\s?\d{3}, which accepts ICS 582 and COE558 and rejects ICS58 (only two digits) and ICSSS 582 (five letters). The habit is the same in both: build the smallest piece that covers the positives, then run it against the nearest negatives.

AddressWhat matchedVerdict
m.alraimi@kfupm.edu.sam.alraimi@kfupm.edu.sawhole address, fine
first-last@x.comlast@x.comhyphen is not in the class, the local part is truncated
a+b@y.orgb@y.orgplus is not in the class either
me@localhostme@localhostaccepted although there is no top-level domain
The simplified email pattern on four addresses

The table is an error analysis, and it exposes two failure modes that pull in opposite directions. The class is too narrow: hyphens and plus signs are legal in local parts, so the pattern silently truncates them and matches the wrong substring. That is a recall problem: real addresses the pattern should catch, it catches only in part. The domain side is too loose: it accepts me@localhost, which has no top-level domain. That is a precision problem: strings the pattern should reject, it accepts. Widening the class to [A-Za-z0-9._+-]+ raises recall; requiring a dot followed by at least two letters at the end, \.[A-Za-z]{2,}$, raises precision. Each fix is one small piece, tested on representative samples, and the point of slide 69 is to iterate this way rather than to write a pattern that fits the one address in front of you.

Capture, then reuse

Once a pattern matches, each capture group has stored a substring, and a substitution can put them back in any order. Slide 70 shows the classic sed notation s/(\d{4})-(\d{2})-(\d{2})/$2$3$1/. Work it on an ISO date.

Worked example

Reordering 2026-01-25

  1. Name the groups

    (\d{4})-(\d{2})-(\d{2}) matches four digits, a hyphen, two digits, a hyphen, two digits. Group 1 is 2026, group 2 is 01, group 3 is 25. The hyphens are matched but not captured.
  2. Apply the slide's replacement

    $2$3$1 writes group 2, then 3, then 1 with nothing between them: 01252026. The separators vanished because a replacement only contains what you write into it.
  3. Write the separators yourself

    Python syntax: re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\2/\3/\1', s) gives 01/25/2026. Swapping the first two references, \3/\2/\1, gives 25/01/2026.
  4. Three replacements, three outputs

    The pattern never changes. Only the replacement string decides the order and the separators.
ReplacementOutputReading
$2$3$101252026slide 70: month, day, year, separators dropped
\2/\3/\101/25/2026US order with slashes written by the replacement
\3/\2/\125/01/2026day, month, year: the slide 72 target
One pattern, three replacement strings, on 2026-01-25

The syntax of the reference differs by tool. sed and Perl write $1 or \1 inside s/.../.../; JavaScript's replace uses $1 and $& for the whole match; Python's re.sub uses \1 in a raw string and \g<0> for the whole match (Python re documentation). SLP3 (section 2.6.7) uses the same trick to move US dates to European order: re.sub(r"(\d{2})/(\d{2})/(\d{4})", r"\2-\1-\3", s) turns 10/15/2011 into 15-10-2011.

A backreference is not only for replacements. Inside the pattern itself, \1 means "the same text that group 1 just captured", so (\w+) \1 finds a doubled word. SLP3's careful version, \b([A-Za-z]+)\s+\1\b, restricts the group to letters, adds word boundaries and allows any whitespace; on the the cat sat on on the mat it returns the and on. This is the mechanism behind the faster-they-ran exercise in the next concept.

Looking without moving

The email and date patterns consume text left to right. Some rules cannot be written that way. A password must be at least eight characters, contain a capital letter, and contain a digit, and the three rules overlap: the same characters count for all of them. Lookahead solves this. (?=...) requires that a pattern could match starting here, (?!...) forbids it, and neither consumes anything. SLP3 (section 2.6.8) puts it precisely: the match pointer does not advance, just as with anchors.

Both lookaheads probe from position 0 and leave the pointer there. Only the final .{8,} consumes the string.

So ^(?=.*[A-Z])(?=.*\d).{8,}$ reads: at the start of the line, check that somewhere ahead there is a capital; still at the start, check that somewhere ahead there is a digit; now, still at the start, consume at least eight characters to the end. Because the two checks both begin at position 0 they can look at the same characters, which a consuming pattern could never do.

CandidateLengthCapitalsDigitsResult
Passw0rdX9P, X0accepted
password8nonenonerejected
PASSWORD19all1accepted
Pw1short8P1accepted, exactly 8
The password pattern on four candidates

Negative lookahead is just as useful. SLP3's equation 2.18, ^(?![tT])(\w+)\b, captures a word at the start of a line only if it does not begin with t or T. Without the lookahead you would need a class that lists every other letter.

Recall

What does re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\2\3\1', '2026-01-25') return, and why is there no separator?

01252026. The hyphens were matched but never captured, and the replacement writes only the three groups, so nothing puts a separator back.

Quick check

After the lookahead ^(?=.*\d) succeeds on Passw0rdX, where does the match pointer stand?

The slide 72 practice set, solved

Slide 72 is a set of six problems with no answers. Each of them uses one tool from the previous two concepts, and together they cover everything the exam can ask about escaping, each capture group and its backreference, and lookahead. Try each one in the workbench before revealing the solution. The check button compares your matches against the expected list and tells you what is missing or extra.

InteractiveRegex workbench: match, substitute and pre-tokenize with live feedback

Match the two tags <Begin> and <\End> and nothing else.

Literal//g
26 / 300
Result

Type a pattern to see live results.

Patterns run in JavaScript syntax inside a worker with a 600 ms limit. Python differs in three places: backreferences in replacements are \1 not $1, named groups are (?P<name>...), and \p{L} needs the regex module.

The six problems map one to one onto the tools of the previous two concepts. The tags problem rewards a negated class or an escaped backslash: <[^>]+> reads as "any run of non-closing-brackets inside brackets". The Apple problem is the one where a global i flag backfires, because it would also lowercase Juice, so the case-insensitivity has to live per letter inside a class like [Aa], or inside a scoped group like (?i:apple). The password problem is the argument for lookahead: three overlapping rules, all checked from the same position, none consuming input. The faster-they-ran sentence needs backreferences, capturing the repeated words once and reusing them as \1 and \2. The date swap is the classic capture-group substitution. The boxes problem shows that the whole-match reference, $& or \g<0>, lets you skip a group entirely when you do not need to rearrange anything.

Recall

Match the tags <Begin> and <\End> in the string <Begin> Hello World <\End>, and nothing else.

<\\?\w+>: an opening bracket, an optional escaped backslash (the closing tag uses a backslash where HTML would use a slash), one or more word characters, a closing bracket. <[^>]+> also works: anything except a closing bracket, inside brackets. Both return <Begin> and <\End>.

Recall

Match Apple Juice, apPLe Juice and APPLE Juice but not Apple juice.

The first word is case-insensitive, the second is not, so a global i flag is wrong. Spell the classes: [Aa][Pp][Pp][Ll][Ee] Juice. Engines with scoped flags allow (?i:apple) Juice (Python re documentation).

Recall

Write a password check: at least 8 characters, at least one capital letter, at least one digit.

^(?=.*[A-Z])(?=.*\d).{8,}$. Two lookaheads from the start, then a consuming .{8,} to the end. On the four test lines it accepts Passw0rdX, PASSWORD1 and Pw1short and rejects password.

Recall

Match both 'The faster they ran, the faster we ran' and 'The faster they read, the faster we read' with one pattern.

Capture the two words that repeat and refer back to them: [Tt]he (\w+) they (\w+), the \1 we \2. Group 1 captures faster, group 2 captures ran or read, and \1 and \2 must reproduce them. A mixed sentence, ran then read, does not match.

Recall

Convert 2026-01-25 to 25/01/2026 with capture groups.

Pattern (\d{4})-(\d{2})-(\d{2}), replacement \3/\2/\1 in Python or $3/$2/$1 in JavaScript. The slashes are written by the replacement, not captured. For the slide's literal input 2026-25-01, the same pattern with \2/\3/\1 gives 25/01/2026; see the errata.

Recall

Convert 'the 35 boxes' to 'the <35> boxes'.

No group is needed if the tool can name the whole match: re.sub(r'\d+', r'<\g<0>>', s) in Python, or "the 35 boxes".replace(/\d+/g, "<$&>") in JavaScript. With a group, (\d+) and the replacement <\1>.

The cheat sheet as reference tables

Slides 73 to 75 are the reference card for the whole regex block: every quantifier, character class, capture group and anchor in one place. They are reproduced here as tables so you can scan them during revision, with the two engine differences that the card leaves implicit: how line anchors behave under multiline mode, and which engines allow a variable-length lookbehind.

Alternation and quantifiers

a|b
alternation: a or b
?
zero or one of the preceding element
+
one or more
*
zero or more
*?
zero or more, lazy: stop as early as the rest of the pattern allows
{N}
exactly N
{N,M}
between N and M

The lazy form matters more than its size suggests. Greedy .* grabs as much as it can and backs off only if the rest of the pattern fails; .*? takes as little as possible and grows only when it must. Part 08 showed the difference on HTML tags.

Pattern collections

[A-Z]
one uppercase ASCII letter
[a-z]
one lowercase ASCII letter
[0-9]
one ASCII digit
[asdf]
one of a, s, d, f
[^asdf]
any one character except a, s, d, f

Groups

(...)
capturing group: match and remember, referenced as \1 or $1
(?:...)
non-capturing group: scope for | or a quantifier, nothing remembered
(?<name>...)
named group; Python spells it (?P<name>...)

General tokens

.
any character except newline
\n
newline
\t
tab
\s
one whitespace character
\S
one non-whitespace character
\w
one word character: letter, digit or underscore
\W
one non-word character
\b
word boundary, zero width
\B
not a word boundary, zero width
^
start of string, or start of line under m
$
end of string, or end of line under m
\\
a literal backslash

Take one input and walk the flags. On the two-line string cat\ncar the pattern ^ca.$ shows the three flags in action. With no flags it matches nothing: ^ wants the very first position, and the dot cannot be a newline, so $ after a 3-letter match can never reach the end. Turning on m gives cat and car, one per line. Adding g reports both; without g a JavaScript exec returns only the first. The i flag is independent: it would let the pattern tolerate Cat too. Each flag flips exactly one assumption, anchor meaning, case, or how many results are returned.

Flags

g
global: report every match, a JavaScript and regex101 flag; Python uses findall and sub instead
m
multiline: ^ and $ also match at each line break
i
case-insensitive
SyntaxNameExampleResult
(?=...)positive lookaheadfoo(?=bar)foo in foobar only
(?!...)negative lookaheadfoo(?!bar)foo in foobaz only
(?<=...)positive lookbehind(?<=#)\d+42 and 8 in #42 and 7 and #8
(?<!...)negative lookbehind(?<!#)\b\d+7 in the same string
Lookarounds, tested on foobar foobaz and on #42 and 7 and #8

One detail to read closely in the table: (?<!#)\b\d+ needs its \b. Without the boundary, the engine could pick up the 2 of #42, because the position before that digit has 4 in front of it, not #, so the negative lookbehind does not object and the lookahead for a digit succeeds. The \b forces the match to begin where a word begins, so the 2 is ruled out and only 7 survives.

Recall

Which lookbehinds does Python re accept, and what do JavaScript and the regex module allow instead?

Python re accepts only fixed-width lookbehind, for example (?<=#) or (?<=a|b). JavaScript (ES2018) and the regex module allow variable-length lookbehind such as (?<=a+).

Part 07 showed you this pattern as a list of six rules; now that you can read regex syntax, walk it character by character. Before GPT-2's BPE merges touch a sentence, one regex cuts it into pieces. On We're 350 dogs! Um, lunch? the pieces are We, 're, ␣350, ␣dogs, !, ␣Um, ,, ␣lunch and ?, nine in all, where ␣ marks a leading space that stays inside the piece. That regex is the pre-tokenizer, and the whole behavior of the tokenizer on numbers, punctuation and spaces is decided by it.

Recall

From part 07: what does a pre-tokenizer do, and why do BPE merges normally not cross its pieces?

It runs a regex that splits text into words with their leading space, digit runs, punctuation, contractions and whitespace before BPE. Pair counts and merges are computed only inside a piece, so a pair split across two pieces is never a candidate and the final tokens are always parts of pieces.
Eight cuts turn the sentence into nine pieces. Each piece keeps its own leading space, and BPE never merges across a cut.

The pattern from the GPT-2 repository's encoder.py is 's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+. It is a disjunction of six alternatives, and the engine tries them left to right at every position, taking the first that matches. Read it as six rules in priority order.

AlternativeMeaningPieces it produced
's|'t|'re|'ve|'m|'ll|'da contraction suffix, tried first're
?\p{L}+an optional space then one or more Unicode lettersWe, ␣dogs, ␣Um, ␣lunch
?\p{N}+an optional space then one or more Unicode digits␣350
?[^\s\p{L}\p{N}]+an optional space then a run that is neither space, letter nor digit!, comma, ?
\s+(?!\S)whitespace not followed by a non-space, so the last space is left for the next word; also any trailing whitespace␣␣ in Hello␣␣␣world
\s+a single whitespace character directly before a non-space that the rule above cannot take: a lone newline or tab before a word⏎ in a⏎b
The six alternatives on We're 350 dogs! Um, lunch? and on Hello world

Order is the design. Contractions come first so that We're becomes We plus 're: at position 0 no contraction matches, so the letter rule takes We and stops at the apostrophe; at position 2 the contraction rule fires before the punctuation rule can grab the apostrophe alone. Had the letter rule come first the result would be the same for We, but if punctuation came before contractions you would get ' and then re, and the model would never learn that 're is one unit.

The space handling is the subtle part. Letters and digits carry an optional leading space, so a word is normally tokenized together with the space before it: ␣dogs is one piece, and the model learns that word-initial pieces look different from word-internal ones. Runs of spaces are handled by \s+(?!\S): whitespace that is not followed by a non-space. On Hello␣␣␣world that alternative takes the first two spaces and stops, because the third space is followed by w, and the third space is then picked up by ?\p{L}+ as part of ␣world. The result is Hello, ␣␣, ␣world. Trailing whitespace at the end of a document also goes to \s+(?!\S), because at the end of the string nothing follows and the negative lookahead succeeds. The final plain \s+ fires only in the one case the rule above cannot handle: a single whitespace character directly before a non-space that no ␣? prefix will take, which means a lone newline or tab before a word. On a⏎b it produces the piece.

Why regex and not re

The slide imports the third-party regex module under the name re. The reason is \p{L} and \p{N}. These are Unicode property classes: every character whose General_Category is Letter, and every character whose category is Number, as defined in Unicode Standard Annex 44. Python's built-in re does not support them; the regex module does, and so does JavaScript with the u flag (regex module documentation, MDN). The payoff is script independence. On مرحبا بالعالم ١٢٣ the same pattern returns مرحبا, ␣بالعالم and ␣١٢٣: Arabic letters are letters and Arabic-Indic digits are digits without any special case in the pattern. A pattern written with [A-Za-z] and [0-9] would still cut at the spaces, because the negated punctuation class excludes whitespace, but all three pieces would come out through the punctuation rule, indistinguishable from runs of symbols.

For the exam, two ways to make a pattern Unicode-aware: use property classes such as \p{L} and \p{N} instead of ASCII ranges, and apply Unicode normalization (NFC or NFKC, from part 05) to the input before matching so that composed and decomposed forms of the same letter cannot split a match.

InteractiveRegex workbench: match, substitute and pre-tokenize with live feedback

Run the GPT-2 pre-tokenizer pattern and read which alternative produced each piece.

Literal/'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu
26 / 300
Result

Type a pattern to see live results.

Patterns run in JavaScript syntax inside a worker with a 600 ms limit. Python differs in three places: backreferences in replacements are \1 not $1, named groups are (?P<name>...), and \p{L} needs the regex module.

What the pieces are for

The purpose of the cut is to separate words, numbers, punctuation, contractions and whitespace before BPE sees the text. Then the merges run inside each piece. This is literally how encoder.py is written: it loops over re.findall(self.pat, text) and calls self.bpe(token) on each piece separately (GPT-2 repository). A merge can join d and ogs inside ␣dogs, but no merge can ever join dogs with the ! that follows it, because they were never in the same piece. Byte-level operation, from part 06, applies inside each piece too, which is why the tokenizer is also a byte-level one.

The cut lines are a design choice, and later tokenizers move them. tiktoken's r50k pattern for GPT-2 is the same language hardened for speed: '(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s, with possessive quantifiers ++ that refuse to backtrack and an explicit end-of-string whitespace rule. cl100k_base, used by GPT-4, caps numbers at three digits with \p{N}{1,3}+ and makes contractions case-insensitive (tiktoken repository). Hugging Face's ByteLevel pre-tokenizer applies the same GPT-2 pattern unless you switch its use_regex option off (Hugging Face tokenizers documentation). SuperBPE (introduced in part 07) goes the other way: after a first stage that learns subwords inside pieces, a second stage is allowed to merge across whitespace, and Liu et al. (2025) report up to 33 percent fewer tokens at a 200k vocabulary. Every one of these is a change to the pre-tokenizer, not to the BPE algorithm.

Recall

Explain each alternative of the GPT-2 pattern on We're 350 dogs!

We by ?\p{L}+; 're by the contraction list; ␣350 by ?\p{N}+; ␣dogs by ?\p{L}+; ! by ?[^\s\p{L}\p{N}]+.

Recall

Name two ways to make a pattern Unicode-aware.

Use Unicode property classes (\p{L}, \p{N}, \p{Nd}) through the regex module or the JavaScript u flag instead of [A-Za-z] and ASCII \d; and normalize the input to NFC or NFKC before matching.

Quick check

In the GPT-2 pre-tokenizer, which alternative produces the piece ' 350' with its leading space?

Slide 78 closes the regex block with four engineering habits. Three of them you have already practiced in this part without being told. The email pattern grew from an error analysis of four addresses: start simple, add cases as failures appear. The workbench tasks are unit tests: a fixed list of inputs with expected outputs, rerun after every edit. The GPT-2 pattern was explicit about Unicode: property classes and, from part 05, normalization before matching. The fourth habit is the one that bites in production.

Catastrophic backtracking

Consider (a+)+b against aaaaaaaa!. The pattern says: one or more groups, each of one or more a's, followed by b. There is no b, so the answer is no. But a backtracking engine does not know that until it has tried every way of splitting the eight a's between the inner plus and the outer plus: all eight in one group, seven then one, one then seven, two then six, and so on. The number of ways to partition n a's into ordered runs is 2^(n-1), and each of them ends at the same exclamation mark. Add one a and the work doubles. This is catastrophic backtracking.

Nested quantifiers branch at every a and every branch ends at the same dead end. A single plus has only one split per start position.

Worked example

Measuring the explosion

  1. Set up two patterns

    (a+)+b and a+b, both run against n a's followed by an exclamation mark, in Python re and in JavaScript.
  2. Time the nested pattern

    Python time roughly quadruples every two extra characters (see the table below), which is doubling per character. JavaScript follows the same curve, roughly seven to fourteen times faster.
  3. Time the flat pattern

    a+b on the same strings stays under 60 microseconds in Python at every n.a+b has only one way to split any run of a's, so the work is at most polynomial (each start position backs off once), never exponential.
  4. Same strings, same answer, more than four orders of magnitude apart

    Run time depends on the structure of the pattern and the shape of the input, not on the length of the pattern.
nPython reJavaScript
2043 ms8.3 ms
22175 ms31 ms
24711 ms126 ms
262900 ms506 ms
Measured time for (a+)+b on n a's and a ! (local runs; a+b was under 0.06 ms throughout)

Russ Cox (2007) documents the classic case: a pattern of a? repeated n times followed by a repeated n times, matched against a string of n a's. At n = 29 Perl needs over sixty seconds, while a Thompson NFA, which simulates all states at once, takes about twenty microseconds. OWASP lists (a+)+ among its evil patterns because a single crafted input can pin a server's CPU, an attack it calls ReDoS. Both sources agree on the cause: an engine that explores alternatives one at a time, on a pattern with overlapping ways to match the same text.

InteractiveRegex workbench: match, substitute and pre-tokenize with live feedback

Watch the engine time as the input grows. Then change the pattern to ^a+b and try again.

Literal/^(a+)+b/g
19 / 300
Result

Type a pattern to see live results.

Patterns run in JavaScript syntax inside a worker with a 600 ms limit. Python differs in three places: backreferences in replacements are \1 not $1, named groups are (?P<name>...), and \p{L} needs the regex module.

Three fixes, in order of preference. Rewrite the pattern so that quantifiers do not nest over the same characters: a+b matches exactly the same strings as (a+)+b. Lazy quantifiers do not prevent the explosion either: (a+?)+b backtracks just like (a+)+b, because laziness only changes the order in which splits are tried, not how many exist. Use possessive quantifiers or atomic groups where the engine offers them: tiktoken's \p{L}++ is precisely this, a plus that refuses to give characters back. Or run a linear-time engine such as RE2, Go's regexp or Rust's regex crate, which reject backreferences and lookaround in exchange for a guarantee that time is linear in the input.

Recall

Give an example of catastrophic backtracking and a fix.

(a+)+b on 26 a's and an exclamation mark takes about 2.9 s in Python because the engine tries every split of the a's. a+b matches the same strings in microseconds.

Quick check

Why does (a+)+b take seconds on aaaaaaaaaaaaaaaaaaaaaaaaaa! while a+b takes microseconds?

Recap

If you remember nothing else

  • The metacharacters . ^ $ * + ? ( ) [ ] { } | \ need a backslash to match literally; \n and \t go the other way and give a plain letter a special meaning.
  • Precedence: parentheses, then counters, then sequences and anchors, then |. (cat|dog)s matches cats and dogs; cat|dogs matches cat and dogs.
  • Capture groups only record. \1 in the pattern or $1 (Python \1) in the replacement reuses them; (?:...) groups without recording.
  • (\d{4})-(\d{2})-(\d{2}) with \3/\2/\1 turns 2026-01-25 into 25/01/2026; slide 70's $2$3$1 gives 01252026 because the separators were never captured.
  • Lookahead (?=...) and (?!...) are zero-width; stacking them expresses overlapping rules such as a password check.
  • \d is Unicode Nd in Python re but ASCII only in JavaScript; \p{L} and \p{N} need the regex module or the u flag.
  • The GPT-2 pre-tokenizer tries contractions, letters, numbers, punctuation and whitespace in that order; BPE merges run inside each piece.
  • \s+(?!\S) eats a run of spaces but leaves the last one attached to the next word.
  • Nested quantifiers such as (a+)+b backtrack exponentially: seconds at 26 characters in Python, microseconds for a+b.

Sources