iKit
Technical · 10 min read ·

5 Common Regex Mistakes That Match Too Much (2026)

Five regex mistakes that make a pattern match too much — greedy .*, unescaped dots, missing anchors, loose alternation, nested quantifiers — with the fix for each.

5 Common Regex Mistakes That Match Too Much (2026)

5 Common Regex Mistakes That Match Too Much

Most broken regexes aren't broken because they match nothing — they're broken because they match too much. The pattern grabs the entire line instead of one field, or accepts exampleXcom as a valid domain, or hangs the process on a 40-character input. Every one of those bugs traces back to the same handful of habits. Here are the five that cause the most over-matching, and the exact fix for each.

TL;DR

  • Greedy .* grabs to the last delimiter; use lazy .*? or a negated class [^"]*.
  • An unescaped . matches almost any character — write \. for a literal dot.
  • Without anchors, a pattern matches any substring, not the whole input.
  • ^cat|dog$ doesn't anchor both words; group it as ^(cat|dog)$.
  • Nested quantifiers like (a+)+ trigger exponential catastrophic backtracking.

Why does my regex match too much?

Two forces cause over-matching, and telling them apart saves a lot of debugging time. The first is greed: quantifiers repeat as much as they can. The second is under-constraint: the pattern simply doesn't say where it should stop.

Greedy quantifiers are the default

Per MDN, quantifiers like *, +, and {n,} are greedy by default — each one "matches the preceding item as many times as possible" before handing control to the rest of the pattern. When the rest fails, the engine backtracks one character at a time. So .* first swallows the entire string, then gives characters back until the pattern can complete. That "swallow everything, then retreat" behavior is the root of mistakes 1 and 5.

Over-matching vs catastrophic backtracking

There's a difference between a pattern that returns the wrong span and one that never returns at all. Over-matching (mistakes 1–4) produces a result — just a wrong one, usually longer than intended. Catastrophic backtracking (mistake 5) is greed turned pathological: the engine explores so many combinations that matching becomes exponential in the input length. Same underlying cause, very different symptom. You can reproduce every example below in the iKit Regex Tester — it runs entirely in your browser, so nothing you paste leaves your machine.

Mistake 1: Greedy .* grabs more than you want

This is the single most common over-match. You want the text inside the first pair of quotes; you get everything up to the last quote on the line.

Why .* matches to the last quote, not the first

Consider extracting the first quoted string from say "hi" then "bye":

/"(.*)"/.exec('say "hi" then "bye"');
// group 1 = 'hi" then "bye'

Because .* is greedy, it races to the end of the string, then backtracks just far enough to leave one " for the closing quote. The result spans both quoted sections. As the javascript.info guide on greedy and lazy quantifiers puts it, the greedy engine "tries to repeat the quantified character as many times as possible" — helpful for \d+, disastrous for .* between delimiters.

Fix: lazy .*? or a negated character class

Adding ? after the quantifier makes it lazy — it repeats as few times as possible and stops at the first closing quote:

/"(.*?)"/.exec('say "hi" then "bye"');
// group 1 = 'hi'

Even better for performance, describe what the field can't contain with a negated character class. [^"]* matches any run of non-quote characters, so there's no backtracking at all:

/"([^"]*)"/.exec('say "hi" then "bye"');
// group 1 = 'hi'

Reach for the negated class whenever the delimiter is a single character — it's both faster and clearer than a lazy dot.

Mistake 2: An unescaped dot matches any character

The dot is the most over-trusted metacharacter in regex. People read example.com as a literal string; the engine reads it as "example, then any character, then com."

Why example.com matches exampleXcom

Outside a character class the dot matches almost anything (every character except line terminators, unless the s/dotAll flag is set). So a naive domain check is far too permissive:

/example.com/.test("exampleXcom"); // true  (wrong)
/example.com/.test("example!com"); // true  (wrong)

Both should be false. The dot happily accepts X or ! in the gap. On user-supplied input this is a real validation hole, not a cosmetic one.

How to escape a dot in regex

Escape it with a backslash so it matches a literal period:

/example\.com/.test("exampleXcom"); // false (fixed)
/example\.com/.test("example.com"); // true  (ok)

Two things worth remembering. Inside a character class the dot is already literal — [.] needs no backslash — which is a tidy alternative to \.. And if you're building the pattern from a variable, escape every metacharacter in it, not just the dot, before dropping it into a RegExp.

Mistake 3: Missing anchors match a substring

A regex without anchors is a search, not a validation. It succeeds if the pattern appears anywhere in the input — which is exactly what you don't want when checking a whole value.

Why /\d{4}/ matches inside a longer number

Say you want to accept a four-digit PIN:

/\d{4}/.test("12345678"); // true  (wrong)
/\d{4}/.test("abc 1234"); // true  (wrong)

Both pass, because \d{4} only needs four digits somewhere. An eight-digit string contains four digits; so does a line with a stray number in it. The pattern never claimed the digits were the whole input.

How to anchor a regex to the whole string

Wrap the pattern in ^ (start) and $ (end) so it has to describe the entire value:

/^\d{4}$/.test("1234");     // true  (ok)
/^\d{4}$/.test("12345678"); // false (fixed)

Now the match must begin at the start and finish at the end, with nothing left over. Two cautions: in JavaScript the m (multiline) flag makes ^ and $ match at line breaks too, which can re-open the substring hole on multi-line input; and prefer test() over match() when you only need a yes/no answer. The mistakes that follow all combine with this one — an unanchored and greedy pattern over-matches twice over.

Mistake 4: Alternation without a group matches too much

The | operator surprises almost everyone the first time, because it reaches further than it looks like it should.

Why ^cat|dog$ does not mean what you think

Alternation has the lowest precedence of any regex operator — lower than the anchors sitting next to it. So ^cat|dog$ doesn't mean "the whole string is cat or dog." It means "starts with cat" or "ends with dog":

/^cat|dog$/.test("category"); // true (starts with cat)
/^cat|dog$/.test("hotdog");   // true (ends with dog)

The ^ binds only to cat and the $ only to dog. Everything on each side of the | is a complete, independent alternative.

Fix: wrap alternatives in a group

Put the alternation in a group so the anchors apply to the whole thing:

/^(cat|dog)$/.test("category"); // false (fixed)
/^(cat|dog)$/.test("dog");      // true  (ok)

Use a non-capturing group (?:cat|dog) when you don't need to reference the match — it's a hair faster and signals intent. The same precedence trap bites inside larger patterns: abc|def+ means abc or de followed by one-or-more f, not (abc|def)+. When in doubt, group the alternation explicitly.

Mistake 5: Nested quantifiers cause catastrophic backtracking

This is greed at its worst. The pattern looks reasonable, passes every quick test, then freezes on one particular input — often one an attacker can supply on purpose (a class of denial-of-service bug known as ReDoS).

Why (a+)+ blows up on long input

The trigger is a quantifier inside another quantifier where both can match the same characters — (a+)+, (a*)*, or (\d+)*. Against input that almost matches, the engine tries every possible way to divide the characters between the inner and outer loops:

// hangs for a noticeable time, then returns false
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaa!");

As the regular-expressions.info write-up on catastrophic backtracking explains, the number of ways to partition the as grows exponentially — roughly 2^n for n characters. The javascript.info article on catastrophic backtracking walks the same explosion step by step. Twenty-odd characters is enough to stall a thread.

How to fix catastrophic backtracking

The reliable fix is to remove the nesting so each character has exactly one way to match. (a+)+ collapses to plain a+:

/^a+$/.test("aaaaaaaaaaaaaaaaaaaaaaaa!"); // fast, false (fixed)

For real patterns the same principle holds: make the alternatives mutually exclusive so they can't both claim the same text. In engines that support them (not JavaScript's RegExp), an atomic group (?>a+) or a possessive quantifier a++ forbids the backtracking outright. Here's the summary of all five:

Mistake Symptom Fix
Greedy .* Matches to last delimiter Lazy .*? or [^x]*
Unescaped . Accepts any character Escape as \.
No anchors Matches a substring Wrap in ^...$
Loose ` ` Anchor binds one side
Nested (a+)+ Exponential hang Flatten to a+

A quick pre-ship checklist that catches most of these:

  • Is every .* or .+ between delimiters lazy or replaced by a negated class?
  • Is every literal dot escaped?
  • Is the pattern anchored if it's validating a whole value?
  • Does any alternation with anchors sit inside a group?
  • Does any quantifier nest inside another over the same characters?

Two of these mistakes show up constantly in real patterns: building a UUID regex usually needs anchors so it rejects a UUID buried in a longer string, and validating a generated key from the iKit Password Generator is where the greedy-vs-anchored distinction actually matters. Test any pattern against real samples in the iKit Regex Tester before it reaches production — over-matching is obvious the moment you see the highlighted span.

References

Related on iKit

Related posts