Published Updated
Most broken regular expressions were tested exactly once, against one string that was already known to match. The useful discipline is the opposite: assemble the inputs that must be rejected, watch where the engine actually starts and stops, and treat every flag as a change to the pattern. This guide is that workflow, plus the two failure modes — runaway backtracking and dialect drift — that only show up after deployment.
Start from the strings that must not match
A regex that matches your happy-path example proves almost nothing, because the pattern was written while looking at that example. What separates a pattern you can ship from one you cannot is the set of near-misses: inputs that are one character away from valid, that a careless quantifier will swallow, or that look like the target but are not.
Build the corpus before you touch the pattern. For each rule, write three groups: strings that must match, strings that must not, and structural edge cases. The third group is the one people skip, and it is where the bugs live — the empty string, a single space, a value with a trailing newline, a line that is only a delimiter, a string with a Unicode character in the middle, a value 10,000 characters long.
Keep the corpus with the code rather than in a browser tab. A regex is a small parser, and a small parser deserves a test file. When someone changes the pattern six months later because it missed one case, the corpus is what tells them which of the other twenty cases they just broke.
- Must match: the canonical examples, plus the ones with unusual but legal punctuation.
- Must not match: the same strings with one character removed, added, or replaced.
- Edge cases: empty string, whitespace only, leading and trailing whitespace, embedded newline, non-ASCII characters, and something far longer than expected.
Anchors, boundaries, and what counts as a match
The single most common regex defect is a pattern that matches a substring when it was meant to validate a whole value. A pattern like \d{4} applied to the string version 20264 finds 2026 and reports success. If the intent was "this string is a four-digit year and nothing else", the pattern needed anchors: ^\d{4}$.
Anchors and boundaries are not the same thing. The caret and dollar sign assert positions in the subject — by default the start and end of the whole string, and with the multiline flag, the start and end of every line. A word boundary, \b, asserts a transition between a word character and a non-word character. It is what you want when extracting a token from running text, and it is not a substitute for anchoring a validation.
Watch the boundaries reported by your tester, not just the pass or fail. A tester that highlights the matched span tells you immediately whether the engine consumed what you expected. Two patterns can both "match" and select completely different regions, and the difference only becomes visible when the match is used for a replacement or an extraction.
Subject: "version 20264"
/\d{4}/ matches "2026" substring, not validation
/^\d{4}$/ no match correct rejection
/\b\d{4}\b/ no match 20264 is one word, no boundary inside
Subject: "a cat." /\bcat\b/ matches
Subject: "concat" /\bcat\b/ no match
Subject: "a\nb" /^\w/g matches "a"
/^\w/gm matches "a" and "b"Groups: capturing, naming, and replacing
Parentheses do two jobs at once, and conflating them causes trouble. They group a sub-expression so a quantifier or alternation applies to the whole of it, and they capture the matched text for later reference. When you only need the first job, use a non-capturing group, (?:...). It keeps the group numbering stable, which means adding a group in the middle of a pattern no longer silently renumbers every backreference and every $2 in a replacement string.
Named groups, written (?<year>...), remove that fragility entirely and make the pattern self-documenting. They are available in JavaScript since ES2018, and in most other modern engines. A replacement can then refer to $<year> instead of a number nobody can verify at a glance.
Test replacements separately from matches. A pattern can select the right span and still produce the wrong output, because the replacement string has its own syntax: a dollar sign followed by a digit is a backreference, $& is the whole match, and a literal dollar sign must be written as two. If the replacement is a function rather than a string, the group values arrive as arguments, which is usually easier to reason about than a dense template.
const pattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
pattern.exec("2026-09-21").groups;
// { year: "2026", month: "09", day: "21" }
"2026-09-21".replace(pattern, "$<day>/$<month>/$<year>");
// "21/09/2026"
"2026-09-21".replace(pattern, (m, y, mo, d) => d + "/" + mo + "/" + y);
// "21/09/2026"
Non-capturing group keeps numbering stable:
/(?:https?):\/\/(\S+)/ group 1 is the host, and stays group 1
/(https?):\/\/(\S+)/ group 1 is the scheme, host is group 2Flags change the answer without changing the pattern
Flags are part of the regular expression, not a display setting. The same pattern with and without the multiline flag answers different questions, and a tester configured differently from your runtime will confidently give you the wrong result. Set the flags in the tester to match the code before you trust anything it shows you.
The global flag deserves particular suspicion, because in JavaScript it makes the regex object stateful. A global regex carries a lastIndex property that advances after each successful test or exec, so calling the same regex three times against the same string returns true, true, false. Sharing a global regex across calls — as a module-level constant, for instance — produces bugs that look random and depend on call order.
The unicode flag is the other one worth understanding deliberately. Without it, a pattern operates on UTF-16 code units, so a character outside the Basic Multilingual Plane is two units and a dot matches only half of it. With it, Unicode property escapes such as \p{Script=Han} become available and escape sequences are validated strictly. What the unicode flag does not do is make \d, \w, or \b Unicode-aware — those remain ASCII-only in JavaScript regardless of flags, which surprises people writing validators for non-English input.
Every flag changes the result. The right-hand column is the behaviour people most often discover by accident.
| Flag | What it does | The surprise |
|---|---|---|
| g | Find every match rather than the first | Makes test() and exec() stateful through lastIndex |
| i | Case-insensitive matching | ASCII folding only unless u or v is also set |
| m | ^ and $ match at line boundaries | Does not change what . matches |
| s | Dot also matches a newline | Without it, /a.b/ fails on a newline between a and b |
| u | Full code-point semantics, enables \p{...} | Does not make \d or \w Unicode-aware |
| v | Superset of u with set operations | Cannot be combined with u; newer runtimes only |
| y | Match must start exactly at lastIndex | Silently fails when lastIndex was not reset |
| d | Adds start and end indices per group | Only affects the result object, never the match |
Greedy, lazy, and why a dot-star overshoots
Quantifiers are greedy by default: they consume as much as they can and then give characters back, one at a time, until the rest of the pattern can succeed. That is why /<(.+)>/ applied to <a><b> captures a><b rather than a. The dot-star ran to the end of the string, then backed up to the last closing bracket it could find, which is the last one, not the first.
Appending a question mark makes a quantifier lazy: it consumes as little as possible and expands only when forced. /<(.+?)>/ on the same subject captures a, which is almost always what was intended. Lazy quantifiers are not universally better, though — they simply fail in the opposite direction, stopping too early when the delimiter can appear inside the content.
The more robust fix is usually a negated character class rather than either flavour of dot. /<([^>]+)>/ says what you actually mean — run to the next character that is not a closing bracket — and it does so without backtracking at all, which makes it both correct and fast. When you find yourself tuning greediness, ask first whether the dot should have been a narrower class.
Subject: "<a><b>"
/<(.+)>/ captures "a><b" greedy, backs up from the end
/<(.+?)>/ captures "a" lazy, stops at the first ">"
/<([^>]+)>/ captures "a" explicit, and cannot backtrack
Subject: 'say "hi" and "bye"'
/".*"/ matches '"hi" and "bye"'
/".*?"/ matches '"hi"'
/"[^"]*"/ matches '"hi"' clearest intentCatastrophic backtracking, and how to spot it early
Backtracking engines — the kind used by JavaScript, Python, Java, .NET, PCRE and most others — explore alternatives until one succeeds or all fail. When a pattern allows the same substring to be divided among its quantifiers in many ways, the number of divisions to explore can grow exponentially with input length. The pattern still terminates; it just may not do so this year.
The classic shape is a quantifier inside a quantifier where the inner one can match the same characters as the outer: (a+)+, (\s*\w+)*, (\d+|\w+)+. Add an anchor and a subject that nearly matches but fails at the very end, and the engine must try every partition before it can conclude failure. On a typical laptop, /^(a+)+$/ against 24 a characters followed by an X already takes hundreds of milliseconds; add ten more a characters and you are into minutes.
This is a denial-of-service vector whenever the subject comes from a user, which is why it has a name — ReDoS. Test it deliberately: take any pattern with nested or adjacent quantifiers, feed it a long run of the character the inner quantifier matches, and terminate it with a character that makes the whole pattern fail. If the tester stalls, the production service will too. The fixes are to make the inner group unambiguous, to use a negated class instead of a permissive one, to bound the repetition with an explicit maximum, or to stop using a regex for a job a parser does better.
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaX")
// 24 "a" then "X": roughly 300 ms
// 34 "a" then "X": roughly 5 minutes
// each extra "a" doubles the work
Why: "aaaa" can be split among the two quantifiers as
(aaaa) (aaa)(a) (aa)(aa) (a)(aaa) (aa)(a)(a) ...
and every split must be tried before failure is certain.
Rewrites that do not backtrack:
/^a+$/ if the inner group was never needed
/^(?:a{1,32})$/ bounded repetition
/^[^b]+$/ negated class, one obvious pathJavaScript is not PCRE
Regex syntax looks portable and is not. A pattern copied from a Perl one-liner, an nginx configuration, a PHP validator, or a Stack Overflow answer may use constructs that JavaScript has no equivalent for — and the failure is often a syntax error at a completely different place than the unsupported construct.
The most frequent import failures are inline flags, atomic groups, and possessive quantifiers. Writing (?i)abc in JavaScript throws "Invalid group" rather than enabling case-insensitivity; the flag belongs on the literal instead. Atomic groups, (?>...), and possessive quantifiers, a++, are PCRE's built-in defences against backtracking blowups, and JavaScript simply does not have them, which is why ReDoS mitigation in JavaScript has to be done by rewriting the pattern.
There are differences in the other direction too. JavaScript has supported variable-length lookbehind since ES2018, while PCRE traditionally required fixed-length lookbehind. And character-class semantics diverge: \d, \w and \b are permanently ASCII-only in JavaScript, whereas PCRE can be built or switched into Unicode mode where they cover other scripts. Whenever you move a pattern between environments, re-run the whole corpus in the new one rather than assuming the syntax check was the hard part.
| Construct | JavaScript | PCRE / Perl |
|---|---|---|
| Inline flags (?i) | SyntaxError: Invalid group | Supported, scoped or global |
| Atomic group (?>...) | Not supported | Supported |
| Possessive quantifier a++ | Not supported | Supported |
| Recursion (?R) and subroutines | Not supported | Supported |
| Extended mode with comments | Not supported | Supported via the x modifier |
| Variable-length lookbehind | Supported since ES2018 | Restricted in older builds |
| Named group syntax | (?<name>...) | (?<name>...) and (?P<name>...) |
| \d \w \b coverage | Always ASCII-only | Unicode-aware when UCP is on |
| Subject anchors | ^ and $ only | Also \A, \z and \Z |
What to remember
- Write the must-not-match and edge-case inputs before you write the pattern, and keep that corpus in the repository next to the code that uses it.
- Anchor a validation with ^ and $, and reserve \b for pulling tokens out of running text; a substring match is not a validation.
- Set the tester's flags to exactly the flags your runtime uses, and never share a global-flagged regex across calls in JavaScript, because lastIndex makes it stateful.
- Prefer a negated character class over a greedy or lazy dot; it states the intent and removes the backtracking at the same time.
- Before shipping any pattern with nested quantifiers, feed it a long run of the repeated character followed by one character that forces failure, and watch the clock.