Regular expressions that do not surprise you
A pattern that works on your three test strings and fails in production usually fails for one of six reasons.
By Ikonode · Published 11 September 2026
Regular expressions have a reputation for being write-only. Most of that reputation comes from a handful of behaviours that are perfectly consistent once you know them and completely baffling until you do.
Greedy by default, and that is usually wrong
.* matches as much as it can and then backs off only far enough to let the rest of the pattern succeed. Run <.*> against <a href="x">text</a> and it matches the entire string, not the first tag — the engine reaches the end, then walks backwards to the last >.
Adding ? makes a quantifier lazy: <.*?> stops at the first >. Lazy quantifiers are the right default when you are matching something delimited, which is most of the time.
There is a better answer than either for the delimited case: a negated character class. <[^>]*> says "everything that is not a closing bracket", which cannot overshoot in the first place, needs no backtracking, and is dramatically faster on long inputs. When a pattern feels like it needs a lazy quantifier, check whether a negated class expresses the same thing more precisely.
. does not match newlines, and ^ is not the start of the string
Two independent flags, routinely confused:
s(dotall) makes.match newline characters. Without it,.stops at line boundaries — which is why a pattern that works on one-line JSON fails the moment the payload is pretty-printed.m(multiline) changes^and$to match at every line break instead of only at the start and end of the input.
If you need "start of input" regardless of the flag, use \A and \z where the flavour supports them. JavaScript has neither, so there it means being deliberate about m.
One more trap in $: in most flavours it also matches immediately before a trailing newline. Validating ^\d+$ against "123\n" succeeds, and a trailing newline is exactly what a form field or a file read hands you.
\d is not always 0–9
In a Unicode-aware mode, \d matches decimal digits from every script — Arabic-Indic, Devanagari, fullwidth — and \w matches letters far beyond ASCII. .NET does this by default. Python 3 does it for text patterns. JavaScript does it under the u flag.
That is often what you want in a search and almost never what you want in a validator, because the numeric parser on the other side will reject a string your regex accepted. When the field must be ASCII digits, write [0-9]. It says what it means and it means the same thing in every flavour.
Groups: capture only what you use
(...) captures; (?:...) groups without capturing. Every capture allocates, and every capture shifts the numbering of the groups after it — so a pattern that stops working after someone added a set of brackets is almost always a group-number bug. Named groups, (?<year>\d{4}), remove the numbering problem entirely and are supported in JavaScript, Python, .NET, PHP, Java and Go.
Related: | has the lowest precedence of any operator, so ^cat|dog$ means "starts with cat, or ends with dog". Almost every use of alternation wants explicit brackets: ^(cat|dog)$.
Catastrophic backtracking is a real outage
(a+)+$ against a long run of a followed by ! does not fail quickly. The engine tries every way of splitting the input between the inner and outer quantifier, which is exponential in the length of the input. A few dozen characters can pin a CPU core for minutes; this is the ReDoS class of denial-of-service bug, and it has taken down large sites through a single pattern in a log parser.
The shape to look for is a quantifier inside a group that is itself quantified, where the inner and outer parts can match the same characters: (\s*)*, (\w+\s?)+, (.*,)*. If you find one, the fix is usually to make the alternatives mutually exclusive so only one split is possible, or to stop using a regex for the job.
Two structural defences worth knowing: Go's and Rust's regex engines are RE2-style, with linear-time guarantees — no backtracking, and no lookaround as the price — and several backtracking engines support atomic groups (?>...) or possessive quantifiers a++ that forbid the re-splitting outright.
Patterns are not portable, even when they look it
Copying a pattern between languages is where a working regex quietly changes meaning:
- Lookbehind
(?<=...)is unsupported in Go and Rust, variable-length in .NET and modern JavaScript, and fixed-length in Python's standard library. - Escaping inside a character class differs: some flavours treat an unescaped hyphen between two escapes as an error, others as a literal.
- The
uflag in JavaScript changes what.counts as one character. Without it, an emoji is two surrogate halves and.matches half of one. - Delimiters and double escaping. A pattern written in a shell, a YAML file or a JSON config passes through one more layer of escaping than the same pattern typed into a tester, which is why
\\dand\dboth show up in configuration files and only one of them is right.
The things regex should not be doing
Email addresses. The grammar in RFC 5322 permits comments, quoted local parts and nested brackets; the "correct" regex is thousands of characters long and still accepts addresses that bounce. Check for one @ with something either side, then send a confirmation message. Delivery is the only real validation.
HTML and nested structures. Regular expressions cannot count, so they cannot match balanced tags or brackets. A parser can. Every "it works for our HTML" pattern is one nested element away from failing.
Anything with a dedicated parser. URLs, dates, CSV with quoted commas, JSON. The parser handles the edge cases you have not thought of yet, and it fails with a position instead of silently matching the wrong span.
Test with the input that will actually arrive
Match the pattern against the empty string, a string with a trailing newline, a line with Windows \r\n endings, a non-ASCII name, and one input ten times longer than you expect — that last one is where a backtracking bug announces itself. Then compare a before-and-after of a bulk replacement rather than eyeballing it: a diff of the whole file shows the three lines the pattern touched that you did not intend, which is the failure mode a spot check is worst at catching.