How a Regex Engine Actually Reads Your Pattern
Most regex confusion comes from treating a pattern as a description of what you want. It is not. It is a set of instructions the engine executes left to right, backing up whenever a step fails.
The engine starts at position 0 and tries to match your pattern's first element there; if every element matches in sequence, you have a match. If any element fails, the engine discards the attempt, advances the start position by one, and tries again. That is why cat finds "cat" inside "concatenate" — the attempt at index 3 succeeds — and why anchoring with ^ makes a pattern dramatically faster: it tells the engine there is exactly one start position worth trying.
Backtracking is the other half. When a quantifier has consumed more than it should, the engine gives characters back one at a time and retries the rest. .+b against aaab swallows all four characters with .+, finds nothing left for b, gives back the final b, then succeeds. Cheap here, catastrophic in the pathological cases below.
Character Classes and the Shorthands Worth Memorising
A character class in square brackets matches exactly one character from the set. [aeiou] matches one vowel; a hyphen creates a range, [a-f]. A caret as the *first* character negates the set, so [^0-9] is any single non-digit. Anywhere else inside brackets, ^ is a literal caret.
Inside a class, most metacharacters lose their power: [.+*] matches a literal dot, plus, or asterisk with no escaping. Only ], \, ^ (leading) and - (between two characters) need care.
| Shorthand | Matches | ASCII equivalent | Unicode caveat |
\d | Digit | [0-9] | Python re also matches Devanagari, Arabic-Indic etc. on str patterns |
\w | Word character | [A-Za-z0-9_] | Python includes accented letters; JS does not unless you build it yourself |
\s | Whitespace | space, \t, \r, \n, \f, \v | JS \s also matches non-breaking space and other Unicode spaces |
\b | Word boundary | zero-width, between a \w and a non-\w or a string edge | Follows whatever \w means in that engine |
. | Any character | everything except line terminators | matches newlines when the s flag is on |
The uppercase forms \D, \W, \S, \B are the exact negations. The Python difference matters: validate a numeric ID with re.match(r'^\d+$', value) and "١٢٣" passes. Add the re.ASCII flag, or write [0-9]+, when you mean ASCII digits.
Quantifiers, Greed, and Laziness
* is zero or more, + is one or more, ? is zero or one, and {n}, {n,}, {n,m} give exact counts. All are greedy by default: they take as much as they can, then hand characters back under backtracking. Appending ? makes a quantifier lazy — it takes as little as possible and expands only when forced. The difference shows up the moment you parse anything with delimiters.
const html = 'bold and italic';html.match(/<.+>/g); // [ 'bold and italic' ] one huge match
html.match(/<.+?>/g); // [ '', '', '', '' ] each tag
html.match(/<[^>]+>/g); // [ '', '', '', '' ] same, no backtracking
The third form is the one to reach for. A negated character class cannot overshoot the delimiter, so the engine never has to back up. Prefer "match everything that is not the terminator" over "match anything, lazily".
A third mode, possessive quantifiers (.++), takes as much as possible and refuses to give any back. PCRE, Java and the third-party regex module have long supported them; Python's standard re gained possessive quantifiers and atomic groups (?>...) in 3.11, and .NET offers atomic groups. JavaScript has neither.
Groups, Captures and Backreferences
Parentheses do two jobs: bind a quantifier to more than one character, and capture what matched.
const log = '2026-03-22T14:05:09Z ERROR payment timeout';
const m = log.match(/^(?\d{4}-\d{2}-\d{2})T(?
m.groups.date; // '2026-03-22'
m.groups.level; // 'ERROR'
m.groups.msg; // 'payment timeout'
Named groups ((?) work in JavaScript since ES2018, in Python as (?P, and in .NET and Java. Use them: positional indices break the moment somebody inserts a group in the middle. When you need grouping but not the text, use (?:...) — (?:https?|ftp):// costs the engine nothing to remember.
Backreferences match what a group already captured: \1 in the pattern, $1 in most replacement strings.
// collapse doubled words: "the the cat" -> "the cat"
'the the cat sat sat'.replace(/\b(\w+)\s+\1\b/g, '$1'); // 'the cat sat'
This is a *backreference* to text already matched, not a re-evaluation of the pattern: (cat|dog) and \1 matches "cat and cat" but not "cat and dog".
Lookaround: Assert Without Consuming
Lookarounds check that something is or is not there without adding it to the match. All four are zero-width:
| Syntax | Name | Matches when |
(?=X) | Positive lookahead | X follows the current position |
(?!X) | Negative lookahead | X does not follow |
(?<=X) | Positive lookbehind | X precedes the current position |
(? | Negative lookbehind | X does not precede |
The classic use is stacking independent conditions on one string, which is how password rules are written:
const strong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{12,}$/;
Each lookahead runs from position 0, confirms its condition somewhere in the string, then releases the position unchanged. Only .{12,} consumes anything.
Support is uneven. JavaScript's lookbehind, added in ES2018 and now in every major engine including Safari's JavaScriptCore, is unbounded — the assertion may be variable-length. Python's built-in re requires fixed-width lookbehind, so (?<=ab|abc) raises an error; the third-party regex package lifts that restriction. Go's regexp, built on RE2, supports no lookaround or backreferences at all, by design: RE2 guarantees linear-time matching, and those features make that impossible.
Catastrophic Backtracking and ReDoS
This is a real denial-of-service class, not a theoretical one. When a pattern nests quantifiers over overlapping character sets, the number of ways the engine can split the input grows exponentially.
const bad = /^(a+)+$/;
bad.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaa!'); // hangs: exponentially many paths before failing
(a+)+ can partition that run of 29 a's in an enormous number of ways, and the engine tries all of them before reporting failure. Anything shaped like (x+)+, (x*)*, (x|xy)+ or (\s+)+$ is a candidate; real outages have come from whitespace trimming and from email validators built this way.
Four defences, best first:
^(a+)+$ is just ^a+$. Rewrite so only one parse exists.regexp, re2 bindings elsewhere) and Rust's regex crate both run in linear time.^(?>a+)+$ and ^(a++)+$ cannot hand characters back, so the exponential split disappears. Available in PCRE, Java, .NET and Python 3.11+, not in JavaScript.Regex constructor takes a matchTimeout directly.Never compile unvalidated user input into a backtracking engine and run it against a large string on the request path.
Patterns That Hold Up in Production
Email is the pattern people get wrong most often. RFC 5322's grammar is genuinely complex, and the short one-liners floating around reject valid addresses like first.last+tag@sub.example.museum. Use the WHATWG HTML pattern, which the spec documents as a willful violation of RFC 5322 and which browsers enforce:
^[a-zA-Z0-9.!#$%&'*+/=?^_{|}~-]+@a-zA-Z0-9?(?:\.a-zA-Z0-9?)*$
It is deliberately permissive — a@b passes, since a bare hostname is a legal destination. Use it for a format check, then confirm deliverability by sending mail. No regex proves an address exists.
Three more. An ISO 8601 calendar date, with month and day ranges enforced:
^\d{4}-(0[1-9]1[0-2])-(0[1-9] [12]\d
3[01])$
Hex colour, accepting 3, 4, 6 or 8 digits:
^#(?:[0-9a-fA-F]{3,4}[0-9a-fA-F]{6}
[0-9a-fA-F]{8})$
Semantic version — the official grammar published on semver.org, not a simplification. The (0|[1-9]\d*) groups reject leading zeros like 01.2.3, and the prerelease alternation rejects 1.2.3-01:
^(0[1-9]\d*)\.(0 [1-9]\d*)\.(0 [1-9]\d*)(?:-((?:0 [1-9]\d* \d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0 [1-9]\d*
\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$
Note the hyphen placement: every - there sits last inside its class. Shortcuts like [0-9A-Za-z-.], with - wedged between a finished range and a literal, are tolerated by loose parsers and rejected by strict ones.
The date pattern still accepts 2026-02-31. Regex counts characters; it does not know how many days February has. Check calendar correctness in code afterwards.
Testing and Two Gotchas That Bite Everyone
Build patterns incrementally against real data. Paste a sample into the Regex Tester and add one element at a time, watching which spans light up. If you know what you want in words but not in syntax, the AI Regex Generator drafts a starting pattern — verify it by hand, since a pattern that looks right on three examples can fail the fourth.
Two JavaScript-specific traps:
const re = /\d+/g;
re.test('a1'); // true
re.test('a1'); // false — lastIndex is now 2, so it resumes past the match
A regex literal with g or y carries mutable lastIndex state. Do not share one across calls to test() or exec(); create it fresh, or reset re.lastIndex = 0. A third call returns true again, because a failed test() resets lastIndex to 0 — which makes the bug intermittent rather than obvious.
Second: String.prototype.replaceAll throws a TypeError if you pass a regex without the g flag, while replace` silently substitutes only the first occurrence. For literal substitutions across a block of text, Find and Replace avoids the question entirely, and Text Diff Checker confirms a bulk replacement changed only what you intended.