Keentune

Regex curriculum

24 chapters
·
213 concepts
·
free
Everything the adaptive question bank can teach and test in Regex, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the Regex guide
A free 18-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. What a regex is and how a match happens
a pattern names a set of strings; matching asks whether the subject contains one
a successful match is a span inside the subject, not the whole input
with no anchor, the pattern may match anywhere in the subject
the engine tries start position 0, then 1, and reports the first start that succeeds
"does it match" and "what did it capture" are different jobs with different APIs
a pattern that can match nothing succeeds at every position, so a* "matches" bbb
a dozen characters carry meaning; every other character stands for itself
matching compares exact characters until i says otherwise
interpolating untrusted text into a pattern is the regex-injection bug
B. Literal characters and escaping
\. matches a period; escaping strips a metacharacter of its meaning
. ^ $ * + ? ( ) [ ] { } | \, plus / inside a JS regex literal
\q is a literal q in legacy mode but a syntax error under u/v
\n, \t, \r, \f, \v, \0 and the \cX control-character form
\xhh and \uhhhh everywhere; \u{10FFFF} only under u or v
new RegExp("\\d") needs two backslashes where /\d/ needs one
inside [] only ] \ ^ - need care, and position can substitute for escaping
RegExp.escape (ES2025) or re.escape; a raw user string is a pattern, not a search term
C. Character classes
[abc] consumes exactly one character drawn from the set
[cat] is c or a or t, and never the word "cat"
[a-z] is a codepoint range, so [z-a] is an error and [A-z] includes punctuation
[^a] matches one character, so it can never match at end of input
[^a] accepts \n even without the dotall flag, unlike .
a hyphen is literal when first, last, or escaped
[a^] matches a caret; only a leading ^ negates
[\d\s.-] unions shorthand escapes with literals
[^\d] is \D, and [^\d\D] can never match anything
[abc] is one comparison; (?:a|b|c) is three branches to backtrack through
D. Shorthand class escapes and the wildcard
. matches any character except line terminators until the dotall flag
\d is [0-9] in JS and PCRE by default, and any Unicode digit for a Python str pattern
\w is [A-Za-z0-9_], and the underscore is the part people forget
\s covers space, tab, CR, LF, FF, VT, and in JS also  and Unicode spaces
\D, \W, \S match exactly what their lowercase forms do not
\w+ rejects café and Ω, which silently breaks a name validator
the same \w is ASCII in JS and Unicode-aware in Python's default str mode
Python's re.A and PCRE2's PCRE2_UCP toggle whether the shorthands see Unicode
E. Unicode mode, property escapes and set algebra
u makes a surrogate pair one unit and turns unknown escapes into errors
without u, . and [] operate on UTF-16 code units and cut an emoji in half
\u{1F600} is only legal under u or v; otherwise it reads as a braced quantifier
\p{L}, \p{N}, \p{Lu} select characters by Unicode general category
\p{Script=Greek} is narrower than \p{Script_Extensions=Greek}
\P{L} is the complement, and inside a v class it composes differently
v (ES2024) adds intersection &&, difference --, and nested classes
\p{RGI_Emoji} can match a multi-codepoint string, which no other escape does
a flag or family emoji is several codepoints; JS has no \X
under u, i folds K to the Kelvin sign and s to the long ſ
precomposed é and e + combining accent are different subjects to the engine
F. Quantifiers and repetition
zero-or-more, one-or-more, and optional
{n}, {n,} and {n,m}, and why {,m} is not an "up to m" form
ab* repeats only the b; (?:ab)* repeats the pair
(?:abc)? makes three characters optional as a unit
(?:a{2}){3} is six, and the same nesting is the ReDoS shape
(\d)+ leaves only the final repetition in the capture
an engine stops a quantified group that matched empty, so it cannot loop forever
\b+ is a syntax error or a no-op depending on the flavor
JS allows up to 2^32−1; PCRE2 caps a braced count at 65535
.*; runs to end of line and walks back to the LAST semicolon
G. Greediness, laziness and possessiveness
a quantifier consumes as much as it can, then gives characters back on failure
appending ? makes a quantifier take the minimum and grow only when forced
one lazy quantifier changes that quantifier, not the rest of the pattern
the match is still leftmost-first; lazy only shortens it at that start
<.*> swallows to the last > on the line, <.*?> stops at the first
<[^>]*> gets the same answer with no backtracking at all
*+, ++, ?+ take the maximum and refuse to give any of it back
\d++9 can never match, where \d+9 matches 19
PCRE2, Java, Ruby and Python's own re from 3.11 have it; JavaScript does not
H. Anchors and input boundaries
they assert a position and consume no characters
m makes ^ and $ match at every line break, not just the ends
Python and PCRE let $ match before a final \n; JS does not
both ignore m, but \Z is flavour-split: strict end in Python, while Perl and PCRE2 let it match before a final newline and reserve \z for the strict end; JS has neither
\A anchors to the subject start and ignores the multiline flag
^…$ is how you say "the entire string", short of fullmatch
^a|b anchors only the left branch; the parentheses are load-bearing
I. Word boundaries
\b is the zero-width seam between a \w and a non-\w, counting the subject edges
\B asserts the two sides are the same kind, word or non-word
because ' is not a word character, \b splits don't in two
\bcafé\b fails where é is outside the engine's word set
\bcat\b also matches beside punctuation, where \scat\s does not
[\b] means the backspace character, not a boundary
wrapping the term in \b is the fix for renaming cat inside concatenate
J. Alternation and its ordering semantics
the first branch that lets the WHOLE pattern succeed is the one taken
cat|catalog matches cat even when catalog sits right there
^a|b$ parses as (^a)|(b$), not ^(a|b)$
(?:…|…) limits the alternation without adding a capture
(?:a|) is a roundabout a?, and a common accidental trailing |
put the likeliest or cheapest branch first; the engine tries them in order
[abc] avoids the branch-and-backtrack that a|b|c pays for
K. Groups: capturing, non-capturing and atomic
groups are numbered by the position of their opening parenthesis
an enclosing group takes the lower number; the inner one follows
index 0 is the whole match, so captures start at 1
(?:…) groups for scope without allocating a capture slot
a group in an untaken branch yields undefined in JS and None in Python, not ""
grouping exists as much to bind a quantifier as to extract text
every capture the engine must record is work you pay for on each attempt
(?>…) throws away the backtracking positions inside it once it has matched
(?>\d+)9 fails on 199 where (?:\d+)9 succeeds
(?=(pattern))\1 reproduces atomic behavior where (?>…) is unsupported
L. Named capturing groups
(?<name>…) in JS, PCRE2 and .NET; (?P<name>…) in Python
a named group still occupies its ordinal number
m.groups.name in JS, m.group("name") or m["name"] in Python
a name preserves capture identity when another group is inserted and positional indices shift
legal only across mutually exclusive alternatives (ES2025), or under a PCRE2 option
$<name> in JS and \g<name> in Python's re.sub
M. Backreferences
(\w)\1 matches a repeated character, not "another word character"
\1 is a backreference in the pattern; $1 is replacement syntax and means nothing there
\k<name> in JS and PCRE2, (?P=name) in Python
a reference to a non-participating group fails in Python/PCRE but matches empty in JS
under i, (\w)\1 matches aA
a backreference is why RE2-style linear engines refuse the feature
\1 where no group 1 exists reads as an octal escape in legacy JS mode
N. Lookaround
(?=…) tests forward and then returns the cursor to where it started
(?!…) succeeds precisely when the sub-pattern cannot match here
(?<=…) and (?<!…) test the text immediately before the position
JS and .NET allow it; Python's re rejects it and PCRE2 bounds it
a group inside an assertion keeps its captured text after the assertion ends
several lookaheads all test from the same spot, giving independent AND rules
^(?=.*\d)(?=.*[a-z]).{8,}$ expresses "must contain" without ordering the classes
capturing inside a lookahead finds overlaps a consuming scan would skip
one rejects a sequence, the other rejects a single character
^(?!.*foo).*$ is the idiom, and why ^[^f]*$ is not the same thing
\B(?=(?:\d{3})+$) marks comma positions without consuming a digit
O. Flags and inline modifiers
i relaxes character comparison, and under Unicode it does full case folding
g changes how the methods iterate, not what the pattern matches
m moves ^ and $, and does nothing to .
s (JS) or re.DOTALL lets . match a line terminator
y requires the match to begin exactly at lastIndex instead of scanning forward
x / re.VERBOSE ignores unescaped whitespace and allows # comments; JS has no such flag
(?i:…) applies a flag to one span, and (?-i:…) removes it
(?i) must be at the start of the pattern in Python
u, v, d, y are JS-only; x and a are Python/PCRE
JS's d flag adds a start/end offset pair for the match and every capture
P. The backtracking engine
the engine explores choices depth-first and rewinds to the last decision on failure
backtracking engines return the first successful path; POSIX engines return the longest
every quantifier and alternation is a branch, and nesting them multiplies the paths
a total failure at index 0 re-runs the whole pattern from index 1
engines pre-scan for a required literal and skip start positions that cannot work
a leading ^ collapses the start-position loop to a single attempt
RE2 and Go's regexp guarantee linear time by dropping backreferences and lookaround
some engines cache (position, state) pairs; a plain backtracker does not
backreferences and recursion put "regex" outside the regular languages
recompiling a pattern inside a loop is the most common regex performance bug
Q. Catastrophic backtracking and ReDoS
a quantifier wrapped around an ambiguous quantified atom, as in (a+)+$
(a|a)* blows up exponentially; \s*\s*$ degrades quadratically
a matching input returns fast; the attack input is the one that ALMOST matches
two ways to split the same text is exactly what multiplies the search space
one crafted request pins a CPU and a single-threaded runtime stops serving everyone
(?:[^"\\]|\\.)* cannot split a character two ways; (?:.|\\.)* can
removing the backtracking positions removes the attack, where the flavor allows it
cap subject length and impose a match limit or timeout before trusting a pattern
escaping stops regex injection; it does nothing about backtracking cost
R. Global and sticky state
a g or y regex object stores where its previous match ended
a module-level /x/g makes repeated test() calls return true, false, true
set it to 0, or construct a fresh regex, before reusing one on new input
a regex literal evaluated inside a loop is a new object each time in modern JS
a manual exec loop must advance lastIndex itself when the match is empty
y is what a tokenizer wants: match here or stop
with both, g yields to y's anchoring at lastIndex
re keeps no state on the pattern; finditer returns an independent iterator
S. Match results and the matching API
the cheapest call when the matched text is not needed
element 0 is the match, then the captures, plus index, input and groups
String.match returns every matched string under g and DISCARDS the captures
every result keeps its captures and its index, and it requires the g flag
the offset of the first match, or -1
re.match anchors at position 0; re.search scans the whole string
requires the entire string to match without bolting on ^ and $
Python's findall returns capture groups, not whole matches, as soon as the pattern has groups
.start(), .end(), .span() in Python; JS's d flag exposes the same offsets
re.compile or a hoisted RegExp, and the small internal cache behind the module functions
T. Replacement syntax
replace(/a/, "b") changes only the first occurrence
passing a non-global regex to replaceAll throws a TypeError
$1, $<name>, and $& for the whole match
` $ ` and $'` insert the text before and after the match
$$ is how a literal dollar sign survives the replacement parser
a non-participating group contributes an empty string, not undefined
the replacement string has its own escape rules; \d there is just d
match, each capture, offset, whole subject, then the named-groups object
a lookup or computation per match, replacing an explicit loop
\1 and \g<name>, the count limit, and subn returning the number replaced
U. Splitting
splitting on /\s*,\s*/ trims while it separates
a capture in the separator is interleaved into the result array
JS's limit truncates the array; Python's maxsplit leaves the remainder in the last element
an empty separator splits between every unit, with surrogate-pair caveats in JS
a separator at the start or end yields an empty string element
re.split gained the ability to split on zero-width matches in Python 3.7
a quoted comma defeats split(","), which is why a parser exists
V. Python's re module in particular
r"\d" stops Python's own string escaping from eating the backslash
a bytes pattern cannot be applied to a str, and its escapes stay ASCII
re.search caches compiled patterns, so re.compile is about clarity and reuse
re.I, re.M, re.S, re.X, re.A, and their inline (?imsxa) equivalents
match objects with positions, versus bare strings or tuples
conditionals exist but recursion and possessive quantifiers need the third-party regex module
if m := re.search(p, s): is the idiomatic test-and-extract
alternation of named groups plus finditer is a workable lexer
re.DEBUG prints the parsed pattern tree, which localizes a precedence mistake
re.error carries the position of the bad construct in the pattern
Match.expand applies replacement syntax to a match you already have
W. PCRE-only constructs
(?1) or (?&name) re-runs a group's pattern without duplicating it
(?R) matches balanced nesting, which no truly regular language can express
(?(1)yes|no) branches on whether a group participated
\K discards everything matched so far from the reported match
\G anchors to the end of the previous match, for contiguous scanning
(*SKIP), (*FAIL), (*PRUNE) steer the backtracker explicitly
(?|…|…) gives each alternative's groups the same numbers
PCRE2_UTF and PCRE2_UCP are what make \w and \b Unicode-aware
the match limit and depth limit are the built-in defense against a runaway pattern
X. Using regex well, and when not to
whitespace and comments turn a symbol wall into something reviewable
two small passes beat one pattern nobody can safely change
escape it, or the user is writing your pattern
a pattern is only as good as the strings you proved it REJECTS
nesting, comments and attribute quoting are decisions a regex cannot make
match something permissive and send a confirmation; the RFC grammar is not the goal
JSON, CSV, URLs and dates come with parsers that already handle the edges
startsWith, includes and a plain split are clearer and faster
name the pattern, state what it must reject, and pin an example of each
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about Regex practice
Also on your phone
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.
© 2026 SportaApp LLC