Development

Regex from scratch: anchors, classes and quantifiers

Regular expressions look like a spell until you realize they are just four ideas stacked together: where the search starts (anchors), what may appear at each position (classes), how many times (quantifiers) and how to group it all (groups and alternation). After that, reading ^\d{4}-\d{2}-\d{2}$ stops being guesswork. This guide builds that vocabulary from scratch, with patterns you actually use, a date, a phone number, a ticket id, and then goes deep into what separates people who write regex for fun from people who ship regex to production: greedy versus lazy, real Unicode, lookahead and lookbehind, and the most expensive trap of all, the catastrophic backtracking that once knocked Cloudflare offline. Every example runs in the [regex builder](tool:regex-builder), which tests the pattern live and explains each symbol.

J-Kit17 min readIntermediate
  • Regex
  • JavaScript
  • Unicode
  • Security
  • Text

Key takeaways

  • Anchors (^ $ \b) match no characters, they pin positions, like start of line or word boundary.
  • Classes say what may appear; quantifiers say how many times. A trailing ? makes the quantifier lazy.
  • Nested quantifiers over ambiguous text cause catastrophic backtracking (ReDoS): the engine can explore 2^(n-1) partitions of an n-character input.
  • Lookahead, lookbehind and named groups have been standard JavaScript since 2018; atomic groups and possessive quantifiers are not, emulate them with (?=(...))\1 when you need to freeze backtracking.

How to read a pattern

A regex describes text using symbols. The engine reads the pattern left to right and tries to fit it against the text, position by position. In JavaScript the pattern becomes a RegExp object applied over the string, that is exactly what the regex builder does under the hood, highlighting each stretch that matched. Reading gets easy once you sort the symbols by their job:

Literals
Ordinary characters (a, 7, -) match themselves.
Anchors
Pin a position without consuming text: ^ $ \b.
Classes
Say which characters fit at a position: [a-z], \d, .
Quantifiers
Repeat the previous item: * + ? {n,m}.
Groups and alternation
Bundle pieces and offer choices: (…) and |.
Assertions
Look around without consuming: lookahead (?=…) and lookbehind (?<=…).

Anchors: where, not what

Anchors are the number-one source of confusion because they match no character: they assert a position. ^ requires the start of the text; $ requires the end. \b is a word boundary, the edge between a word character (\w) and a non-word one, handy so "cat" is not captured inside "category". \B is the opposite: a position that is not a word boundary. Because anchors consume no text, they are the cheapest antidote to backtracking: a $ at the end of a pattern tells the engine "either close here or give up", cutting short the tour through combinations it would otherwise take.

^abc   → casa "abc" apenas no início
abc$   → casa "abc" apenas no fim
\bfoo\b → casa "foo" isolada, não "food" nem "barfoo"
Anchors assert positions; they consume no characters.

Character classes and Unicode

A class in square brackets matches exactly one character from the set. [abc] matches a, b or c; [a-z] uses a range; [A-Za-z0-9] mixes several. A ^ right after the bracket negates the class: [^0-9] matches anything that is not a digit. Inside brackets almost every metacharacter loses its power, a dot there is just a dot. The most common sets have shorthands, and each has an uppercase negation:

Class shorthands and their negations.
ShorthandEquivalent toNegation
\d[0-9] (digit)\D (non-digit)
\w[A-Za-z0-9_] (word, ASCII only)\W (non-word)
\sspace, tab, newline\S (non-space)
.any character except a line break(use the s flag to include \n)
\p{L}any Unicode letter (needs the u flag)\P{L} (non-letter)

Look at the second and last rows: \w is ASCII only. It equals [A-Za-z0-9_] and excludes accents, "coração", "José" and "ñ" fall outside it. This is the silent mistake number one in non-English text. The modern fix is the u flag plus property escapes: \p{L} matches any Unicode letter, \p{N} any number, \p{P} punctuation. It is the same reason the character counter uses \p{L} instead of \w to count words. The \p{…} property escapes and the u flag have been standard ECMAScript since ES2018.

// Contar "palavras" em português
"São Paulo".match(/\w+/g)     → ["S", "o", "Paulo"]   // \w quebra no "ã"!
"São Paulo".match(/\p{L}+/gu)  → ["São", "Paulo"]      // \p{L} + flag u acerta
Without the u flag and \p{L}, the accent becomes a boundary and splits the word into pieces.
Why \w ignores ç, é and ã

\w was defined in the 1980s as an ASCII shorthand: exactly [A-Za-z0-9_]. It never knew what an accent is. You have two honest ways out: list the letters by hand, like [A-Za-zÀ-ÿ], which covers Latin-1 but forgets other scripts; or turn on the u flag and use \p{L}, which hands the "is this a letter?" question to the Unicode database. The second is the right one for real text, and the only one that also gets Greek, Cyrillic and every letter-like symbol that shows up.

Code point vs grapheme: why /./u miscounts emoji

Without the u flag the dot matches one UTF-16 code unit, not a character. Since almost every emoji lives above U+FFFF, it is stored in two code units (a surrogate pair), so "😀".length is 2 and /./g finds two matches where you see one. With the u flag the dot matches a full code point: /./gu finds a single match in "😀". But that is where the next gotcha hides.

A grapheme, what the eye calls "one character", can be several code points. The flag "🇧🇷" is two regional indicators (two code points, four code units); "👍🏽" is the thumb plus a skin-tone modifier. Against "🇧🇷", /./gu returns 2, not 1, because it counts code points. To count real graphemes, the ruler a human would use, the right tool is not regex but JavaScript’s own Intl.Segmenter, which groups code points into grapheme clusters.

The v flag (unicodeSets): the 2024 upgrade

ECMAScript 2024 added the v flag, a superset of u. It turns on set notation inside classes: intersection with &&, difference with --, and nested classes. For instance, [\p{Script=Greek}&&\p{Letter}] matches only Greek letters. Rules: u and v are mutually exclusive (using both throws a SyntaxError), and the v flag has shipped in current browsers since 2023. The regex builder on this page exposes u but not v, for most everyday patterns, u is already enough.

Quantifiers: greedy, lazy and possessive

A quantifier repeats the item immediately before it. By default they are greedy: they grab as much text as possible and only give some back (backtrack) if the rest of the pattern fails to fit. Adding ? makes the quantifier lazy, it grabs the minimum and only grows if it has to. There is a third mode, possessive, which grabs the maximum and never gives it back: the most direct defense against backtracking. The catch for JavaScript users is that the language has neither possessives nor atomic groups, but they can be emulated, and we will get there.

Quantifier modes. The last two rows do NOT exist in standard JavaScript.
FormBehaviorIn JavaScript?
.* .+ (greedy)Grabs the maximum; backtracks if the rest fails.Yes
.*? .+? (lazy)Grabs the minimum; grows only if needed.Yes
.*+ .++ (possessive)Grabs the maximum and never backtracks.No
(?>…) (atomic group)Freezes what matched; drops the backtrack points.No
(?=(…))\1 (emulated atomic)Standard trick to simulate an atomic group in JS.Yes (idiom)

The classic case almost everyone gets wrong is pulling out HTML tags. The dot matches anything, so a greedy <.*> swallows too much. Compare the three patterns over the same text:

Texto:  <b>negrito</b>

<.*>     (guloso)      → casa "<b>negrito</b>"  , uma ocorrência, engole tudo
<.*?>    (preguiçoso)  → casa "<b>" e "</b>"   , duas ocorrências, para no 1º >
<[^>]*>  (classe certa) → casa "<b>" e "</b>"   , igual, e sem risco de backtracking
Greedy vs. lazy vs. specific class. The third form is the safest: [^>] cannot "run past" a >.

The lesson is worth gold: replacing .* with a negated class like [^>] solves the same problem the lazy quantifier would solve, but without leaving the engine backtrack points to explore. Whenever the delimiter is known (a >, a quote, a comma), prefer [^delimiter]* over .*?. You gain clarity and armor the pattern against the backtracking blow-up of the last section.

Try it yourself: paste "<b>negrito</b>" into the text and switch between <.*> and <.*?> to see the difference live.Open the tool full page

First worked example, now numeric extraction: \d+(?:[.,]\d+)? applied to "Total: R$ 123.45 (split into 3x of 41.15)" with the g flag returns three results, 123.45, 3 and 41.15. The \d+ grabs the integer part; the optional group (?:[.,]\d+)? captures the decimal part when it exists (which is why the "3", with no decimals, also matches). That is one of the ready-made patterns in the regex builder’s library.

Groups, flags, lookahead and lookbehind

Parentheses group a stretch so you can apply a quantifier to it or capture the matched value. (\d{4}) creates a capturing group, the engine stores what matched so you can reuse it (for instance, in a replacement with $1). If you only want to group, without storing, use a non-capturing group (?:…), which is lighter. Named groups (?<year>\d{4}) name the value, and you retrieve it in a replacement as $<year>. The pipe | is alternation: cat|dog matches either word. Named groups, like lookbehind, have been standard JavaScript since ES2018.

// Segundo exemplo trabalhado: reformatar data com grupos nomeados
"05/05/2026".replace(
  /(?<dia>\d{2})\/(?<mes>\d{2})\/(?<ano>\d{4})/,
  "$<ano>-$<mes>-$<dia>"
)
→ "2026-05-05"
Named groups on capture and $<name> in the replacement turn dd/mm/yyyy into yyyy-mm-dd.

Lookahead and lookbehind are assertions: they inspect what comes before or after the current position without consuming that text. There are four, and the table below is the reference you will keep coming back to. The most common use is to validate without capturing, for example, requiring a password to have at least one digit with (?=.*\d), without that piece becoming part of the match.

The four lookaround assertions. All standard in JavaScript (lookbehind since ES2018).
SyntaxNameAsserts the position is…
(?=…)Positive lookahead…followed by …
(?!…)Negative lookahead…not followed by …
(?<=…)Positive lookbehind…preceded by …
(?<!…)Negative lookbehind…not preceded by …
// Extrair só o número depois de "R$", sem levar o "R$" junto
"R$ 123,45".match(/(?<=R\$\s?)\d+(?:[.,]\d+)?/)  → "123,45"

// Casar "gol" só quando NÃO for seguido de "eiro" (evita "goleiro")
/gol(?!eiro)/  → casa "gol" em "gol de placa", não em "goleiro"

// Separador de milhar: um dígito seguido de grupos de três até a borda
"1234567".replace(/\d(?=(\d{3})+\b)/g, "$&.")  → "1.234.567"
Lookbehind grabs what comes after a prefix; lookahead filters by what lies ahead, consuming neither.
  1. ES2015The u flag (Unicode mode)

    ECMAScript 2015 (ES6) brings Unicode mode: the dot and classes start reasoning per code point, and the road to \p{…} opens up.

  2. ES2018Lookbehind, named groups, \p{…}, dotAll

    The big harvest: lookbehind (?<=) and (?<!), named groups (?<name>…), \p{…} property escapes and the s (dotAll) flag all land at once.

  3. ES2022The d flag (match indices)

    The d flag (hasIndices) exposes the start and end positions of each matched group, handy for highlighting stretches of text.

  4. ES2024The v flag (unicodeSets)

    Set classes with intersection (&&), difference (--) and nesting, plus \p{…} for string properties.

Flags change the behavior of the whole engine and go after the pattern. The regex builder exposes six: g (global, all occurrences), i (case-insensitive), m (multiline, ^ and $ per line), s (dotAll, the dot now matches line breaks), u (Unicode mode, needed for \p{…}) and y (sticky, matches from a fixed position). Modern JavaScript has two more the tool does not use: d (since ES2022, exposes each group’s indices) and v (since ES2024, the superset of u). In find and replace text, ticking "case sensitive" simply turns the i flag on or off.

Pitfalls: escaping, backtracking and ReDoS

To match a metacharacter literally, escape it with a backslash: \. matches a dot, \? matches a question mark, \( matches a parenthesis. Forgetting this is the most common mistake, 3.14 with the pattern 3.14 also matches "3x14", because the dot is a wildcard. A language detail: inside a JavaScript string literal you double the backslash ("\\d"), but in the regex builder’s field you type the pattern directly (\d), with no extra backslash. With escaping out of the way, the truly expensive trap remains.

Catastrophic backtracking happens when the pattern leaves the engine too many ways to split the same text. Take (a+)+ against a string of "a"s that ends in a character that makes the pattern fail, like "aaaaaaaaaaX". The inner a+ can take 1, 2, 3… "a"s; the outer group repeats that; and there are 2^(n-1) ways to partition a run of n "a"s into blocks. Because the trailing X never matches, the engine tries every one of those partitions before giving up. For n = 30 that is over half a billion attempts, the browser freezes. JavaScript’s regex engine is backtracking-based, so it is vulnerable; the regex builder on this page caps at 1000 matches per run precisely so a pathological pattern cannot lock the tab.

0131,072262,144393,216524,28821120Input length (n)Partitions explored (theoretical)(a+)+, backtracking, 2^(n-1)a+, linear, n steps
THEORETICAL growth: the number of partitions a backtracking engine may explore over a run of n identical characters. The "(a+)+" curve is 2^(n-1) (a combinatorial count, not measured milliseconds); the "a+" curve is linear. Notice how the linear one looks glued to the axis, that is exactly the problem.
View the data
x(a+)+, backtracking, 2^(n-1)a+, linear, n steps
222
484
6326
81288
1051210
122,04812
148,19214
1632,76816
18131,07218
20524,28820
2^(n-1)partitions the engine may explore over an ambiguous run of n characters
~27 minglobal Cloudflare outage on 2 July 2019 caused by backtracking in a WAF rule
0atomic groups or possessive quantifiers in standard JavaScript

This is not lab theory. On 2 July 2019, Cloudflare went down for about 27 minutes, taking a good chunk of the internet with it, because of a single WAF rule. The culprit was .*(?:.*=.*): two greedy .* fighting over the same text. Against legitimate requests, the engine fell into backtracking and pinned the CPU across the entire global network. In the official post-mortem, Cloudflare was blunt: "the regular expression engine being used didn’t have complexity guarantees". The fix included moving to engines with runtime guarantees, such as RE2 and the Rust regex engine.

How to armor a pattern against ReDoS

Avoid a quantifier inside a quantifier over the same alphabet, (a+)+, (.*)* and (a|a)* are the archetypes. Replace an open .* with a specific class that excludes the delimiter, like [^>]* or [^"]*. Anchor the pattern: a ^ at the start and a $ at the end strip the engine of its backtrack positions. Prefer exact quantifiers ({4}) to loose ranges when you know the length. And always test with a long, deliberately failing input before you trust the pattern.

Since JavaScript has neither atomic groups nor possessives, the standard trick to "lock" a stretch is to wrap it in a capturing lookahead and reference the capture right after: (?=(a+))\1. The lookahead matches a+ greedily, the capture stores the result and the backreference \1 consumes it leaving no backtrack points, the practical effect of an atomic group. Beyond that, validate the input length in code before running the regex: no expression needs to chew through 10 MB at once.

RE2 and linear engines: why they drop lookaround and backreferences

RE2 (from Google) and the Rust regex engine do not backtrack. They compile the pattern into an automaton and scan the input once, with time guaranteed linear in the length of the text, immune to ReDoS. The price is that they refuse to implement features for which only backtracking solutions are known: backreferences and lookaround (lookahead/lookbehind) are left out. In RE2’s own words, it "does not support constructs for which only backtracking solutions are known to exist". It is a conscious trade: you lose two features and gain the guarantee that no malicious input can take the service down, which is exactly why Cloudflare switched.

PCRE, Python and ECMAScript: same bones, different flavors

JavaScript (the ECMAScript standard) is not identical to the PCRE used in PHP, Perl or grep -P, nor to Python’s re module. The basics, anchors, classes, quantifiers, alternation, are the same, but the details diverge: JS has no atomic groups (?>…) or possessives (a++); PCRE anchors \A and \z do not apply in JS (use ^ and $); and property syntax and group naming vary. Before copying a pattern from the internet, confirm which engine it was written for and test in the target one. If the pattern is just one step in a pipeline, pair it with the guide on JSON and YAML conversion to extract and then structure the result.

Frequently asked questions

What is the difference between * and +?
* matches zero or more repetitions, the item may not appear at all. + matches one or more, it requires at least one occurrence. So \d* matches an empty string, but \d+ only matches if there is at least one digit.
What is catastrophic backtracking (ReDoS) and how do I avoid it in JavaScript?
It is when a pattern with nested quantifiers over ambiguous text, like (a+)+ or .*.*, pushes the engine to try an exponential number of input partitions before it fails, pinning the CPU. Since JavaScript has neither atomic groups nor possessive quantifiers, prevent it: replace .* with specific classes like [^>]*, anchor the pattern with ^ and $, do not nest quantifiers, and if you need to lock a stretch, emulate an atomic group with (?=(…))\1. A pattern of exactly this kind knocked Cloudflare offline in 2019.
Can I validate an email with a regex?
You can check the format, not the existence. RFC 5322 is so permissive that a "complete" regex becomes huge and unsafe; that is why the WHATWG HTML standard deliberately uses a simple, admittedly non-conforming version. In practice, check only the basics (an @, a domain with a dot) and confirm the address by sending an email with a verification link or code.
Why does \w not match "ç" or "é"?
Because \w is an ASCII shorthand: it equals [A-Za-z0-9_] and was defined before Unicode took over. To match accented and non-Latin letters, turn on the u flag and use the \p{L} property escape (any Unicode letter). That is why tools that count words in non-English text use \p{L}, not \w.
Does JavaScript have lookbehind and named groups?
Yes, both have been standard since ECMAScript 2018. Lookbehind comes in two forms, (?<=…) positive and (?<!…) negative, and JavaScript even allows variable-length lookbehind. Named groups use (?<name>…) on capture and $<name> in the replacement. What JS lacks are atomic groups and possessive quantifiers.
How do I stop a regex from "eating" too much text?
Greedy behavior is the default. Make the quantifier lazy with ? (for example .+? instead of .+) or, better still, replace the dot with a specific class that excludes the delimiter, like [^>]+ to match up to the next ">". The specific class is safer because it leaves no backtracking points.
Does the same regex work in JavaScript and Python/PHP?
The basics (anchors, classes, quantifiers) are the same, but there are differences: atomic and possessive groups exist in PCRE and not in JS; the \A and \z anchors are PCRE-only; and group naming and Unicode syntax vary across ECMAScript, PCRE and Python’s re module. Always test in the target engine before trusting the pattern.

Read every regex by function: anchors pin the position, classes say what fits, quantifiers say how many times and groups organize the rest. Master greedy versus lazy, use \p{L} with the u flag so you never lose accents, and know lookahead, lookbehind and named groups, standard JavaScript since 2018. Above all, distrust nested quantifiers: they cause the catastrophic backtracking that once knocked Cloudflare offline. Prefer specific classes over .*, anchor your patterns and test everything live in the regex builder.

Sources & references

  1. MDN, Regular expressions (reference guide)
  2. ECMAScript, RegExp (language specification)
  3. MDN, Unicode character class escape (\p{…})
  4. OWASP, Regular expression Denial of Service (ReDoS)
  5. Cloudflare, Details of the outage on July 2, 2019
  6. Google RE2, non-backtracking regex engine (linear time)
  7. WHATWG HTML Standard, valid email address (willful violation of RFC 5322)