What a Regex Engine Actually Does
A regular expression is a pattern that describes a set of strings. The engine reads your pattern left to right and, at each position in the input, tries to match the next piece. When a piece fails, most engines backtrack: they return to the last decision point, try the next alternative, and continue.
That one sentence explains most confusing regex behaviour: why .* swallows the whole line and then gives characters back one at a time, why alternation order matters, and why some patterns take milliseconds on one input and minutes on another.
Two engine families exist. Backtracking engines (JavaScript, Python re, Java, PCRE, .NET) support backreferences and lookarounds but can blow up exponentially. Automaton engines (Go's regexp, Rust's regex crate, RE2) guarantee linear time in the input but drop both constructs. If your pattern works in Python and errors in Go, this is usually why.
The Building Blocks
Literals match themselves. cat matches the three letters c, a, t anywhere in the input, including inside concatenate.
Character classes match one character from a set:
[abc] a, b, or c
[a-z] any lowercase ASCII letter
[^0-9] any character that is NOT a digit
\d a digit
\w a word character: [A-Za-z0-9_]
\s whitespace: space, tab, newline, and friends
. any character except newline (unless the s flag is set)
The uppercase forms are negations: \D, \W, \S. Inside [...], most metacharacters lose their meaning — [.+*] is a literal dot, plus and asterisk, no escaping needed. Outside a class they must be escaped: \., \+, \*.
One portability trap: in JavaScript \d is always exactly [0-9] and \w is always ASCII. In Python 3, \d on a str matches Unicode digits such as ٣ (Arabic-Indic three) unless you pass re.ASCII. Do not assume \d means "ASCII 0 to 9" in every language.
Quantifiers repeat the thing before them:
a* zero or more
a+ one or more
a? zero or one
a{3} exactly three
a{2,5} two to five
a{2,} two or more
Anchors and Word Boundaries
Quantifiers and classes tell the engine *what*; anchors tell it *where*.
^— start of the input, or start of a line with themflag$— end of the input, or end of a line withm\b— a word boundary: the position between a\wand a non-\w\B— anywhere that is not a word boundary
One dialect difference matters more than it looks. In JavaScript, $ without m means the very end of the string. In Python, $ also matches immediately before a trailing newline, so re.search(r"^abc$", "abc\n") succeeds. When validating a line read from a file, use \Z in Python or strip the newline first.
Anchors match positions, not characters, so they consume nothing. This is the difference between validating and searching. \d{3} finds three digits *somewhere* in abc123def. ^\d{3}$ matches only a string that is exactly three digits.
/^\d{3}$/.test("123"); // true
/^\d{3}$/.test("a123b"); // false
/\d{3}/.test("a123b"); // true
\b is what you want for whole-word search and replace. /cat/ hits concatenate; /\bcat\b/ does not. That single fix solves most "my find and replace mangled the file" problems — test it before running a Find and Replace pass on real content.
Groups, Alternation and Capture
Parentheses do two jobs: they group so a quantifier applies to more than one character, and they capture the matched text for later use.
(ab)+ one or more repetitions of "ab"
(cat|dog) either "cat" or "dog"
(?:cat|dog) same, but non-capturing
(?\d{4}) named capture group
Use (?:...) whenever you only need grouping. Non-capturing groups keep your group numbers stable and save the engine a little work.
Named groups are the readable option. JavaScript and .NET use (?; Python uses (?P:
const m = "2026-03-10".match(/^(?\d{4})-(?\d{2})-(?\d{2})$/);
m.groups.y; // "2026"
m.groups.mo; // "03"
"2026-03-10".replace(
/(?\d{4})-(?\d{2})-(?\d{2})/,
"$/$/$"
); // "10/03/2026"
import re
m = re.match(r"^(?P\d{4})-(?P\d{2})-(?P\d{2})$", "2026-03-10")
m.group("y") # '2026'
Alternation is ordered in backtracking engines: the engine tries the left branch first and only moves right if the whole rest of the pattern fails. (a|ab) matching against ab captures just a. Put the longer, more specific alternative first.
Flags and Dialect Differences
Flags change how the whole pattern behaves. The common ones:
| Flag | Meaning | JavaScript | Python |
i | Case-insensitive | /x/i | re.I |
g | Find all matches, not just the first | /x/g | use re.findall / re.finditer |
m | ^ and $ match at line breaks | /x/m | re.M |
s | . also matches newline | /x/s | re.S |
u | Unicode mode, enables \p{...} | /x/u | no equivalent; str patterns are Unicode already |
x | Ignore whitespace, allow comments | not supported | re.X |
Two things bite people here. First, g is not a free "match more" switch: a /g regex object in JavaScript carries a mutable lastIndex, so reusing the same object across .test() calls gives alternating true/false results. Create the regex fresh, or use String.prototype.matchAll.
Second, \p{...} Unicode property escapes require the u or v flag in JavaScript. /\p{L}+/u matches runs of letters in any script — far better than [a-zA-Z]+ for names, and the right building block if you are normalising text before feeding it to something like a Slug Generator. Python's built-in re has no \p{...} at all — re.compile(r"\p{L}") raises error: bad escape \p, and you need the third-party regex module instead.
Greedy, Lazy, and the Classic HTML Trap
By default quantifiers are greedy: they take as much as possible, then give characters back until the rest of the pattern matches. Adding ? makes them lazy: take as little as possible, then extend.
Input: bold and italic<.+> matches: bold and italic (one huge match)
<.+?> matches: then then ... (four matches)
<[^>]+> matches: then then ... (four matches, faster)
All three are valid patterns; only the last two do what you meant. [^>]+ is the best of the three because it cannot overshoot at all, so the engine never backtracks. Prefer a precise character class over . whenever you know what the field cannot contain.
Patterns Worth Keeping
Each pattern sits below its label; the labels are not part of the pattern. Test each against your own data before shipping it:
Pragmatic email check (see the caveat below)
^[^@\s]+@[^@\s]+\.[^@\s]{2,}$
ISO 8601 date, structure only
^\d{4}-(0[1-9]
1[0-2])-(0[1-9] [12]\d
3[01])$Hex colour, 3 or 6 digits
^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
Strong password: lower, upper, digit, symbol, 12+ chars
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{12,}$
Trailing whitespace on any line (needs the m flag)
[ \t]+$
The password pattern uses lookaheads. (?=...) asserts that what follows matches, without consuming it, so four lookaheads stack four independent requirements at the same position before .{12,} measures the length. (?!...) is the negative form; (?<=...) and (? look backwards. Lookbehind landed last in Safari 16.4, so it is available in every current major browser and in Node, but it is one of the constructs Go's regexp and Rust's regex deliberately omit.
Two caveats on that password pattern: . does not match a newline without the s flag, and in JavaScript {12,} counts UTF-16 code units, so an emoji outside the Basic Multilingual Plane counts as two unless you add the u flag.
On email: the RFC 5322 address grammar allows quoted local parts, comments and folded whitespace, and a regex implementing it faithfully runs to several thousand characters. Do not chase it. Use a loose structural check, then send a confirmation email — delivery is the only real validation. The HTML spec's pattern is a deliberate simplification that the spec itself labels a willful violation of RFC 5322, and it is a reasonable thing to copy.
When a Pattern Hangs: ReDoS
Nested quantifiers over overlapping sets are the danger sign:
^(a+)+$
Against aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!, the engine must try every way of splitting those a characters between the inner and outer quantifier before concluding there is no match. The number of splits grows exponentially, so each extra character roughly doubles the work. Feed a user-supplied string to a pattern like this and you have a denial-of-service bug, not just a slow function.
Defences, in order of preference: avoid nesting a quantifier inside a quantified group; make inner classes non-overlapping so there is only one way to split the input; cap input length before matching; and for untrusted patterns use an automaton engine where linear time is guaranteed.
Debugging a Pattern That Will Not Match
Work by subtraction. Start from the smallest piece that matches, then add one construct at a time and re-run against a real sample — not an idealised one you typed by hand. Most failures come from four causes: an unescaped ., ? or +; a missing m flag when $ should mean end-of-line; greedy .* reaching past the delimiter; or invisible characters, such as a trailing \r from a Windows file or a non-breaking space that JavaScript's \s does match but you were not expecting.
A live matcher shortens this loop, since it highlights which characters each group captured. Paste the pattern and a realistic sample into the Regex Tester and expand it one token at a time. If you know the shape of the data but not the syntax, the AI Regex Generator will draft a starting pattern — read what it produces, then harden it by hand against the greediness and backtracking issues above.