Why Your Regex Doesn't Match: The 7 Most Common Mistakes
A regular expression that matches nothing feels like a bug in the regex engine. It almost never is. The engine did precisely what the pattern said; the pattern just said something other than what you meant.
Here are the seven ways that happens, in rough order of how often they bite. Each one has a concrete broken pattern, the reason, and the fix. Try them as you read — the regex tester highlights matches live, which is faster than reasoning about any of this in your head.
1. Greedy quantifiers swallow too much
The pattern:
<.+>
Against <b>one</b> and <b>two</b> you expect four matches. You get one: the entire line.
+ is greedy. It consumes as much as it possibly can, then hands characters back one at a time only until the rest of the pattern can succeed. Since the final > on the line satisfies >, it stops there and never backtracks further.
The fix is a lazy quantifier — add ?:
<.+?>
Now it gives back as soon as the pattern can succeed, stopping at the first >. The same applies to *?, {2,}? and friends. If a match is longer than you expected, greed is the first suspect.
2. An unescaped dot matches anything
The pattern:
3.14
matches 3.14, and also 3x14, 3914 and 3 14. The dot is a wildcard, not a full stop.
3\.14
The same trap is set by + * ? ( ) [ ] { } ^ $ | and the backslash itself. Inside a character class most of them lose their special meaning, which is why [.] works too and is sometimes easier to read than the escape.
Watch this one in file extensions and domains: report.pdf as a pattern matches reportXpdf, and example.com matches exampleZcom.
3. ^ and $ are anchored to the whole input
The pattern:
^ERROR
run against a multi-line log finds nothing unless the very first line starts with ERROR. By default ^ means start of input and $ means end of input — not start and end of line.
Turn on the m flag and both anchors switch to per-line behaviour:
/^ERROR/m
This is the single most useful flag for anything log-shaped, and its absence is why so many "my pattern works in the tester but not in my code" reports turn out to be a missing flag rather than a missing feature.
4. . does not cross a newline
The pattern:
<title>(.*)</title>
works on a one-line document and fails the moment the title spans two lines. The dot matches any character except a line break.
Two fixes. The s flag (dotAll) makes the dot include newlines:
/<title>(.*?)<\/title>/s
Or, if you do not want the dot to be that permissive everywhere, match on a character class that explicitly includes everything: [\s\S]*?. That trick predates the s flag and still shows up in a lot of code.
5. \d and \w are narrower than you think
In JavaScript, without the u flag, \w is exactly [A-Za-z0-9_]. It does not match accented letters. A name validator built on ^\w+$ quietly rejects José, Müller and Łukasz — and it will pass every test you write in English.
If you need letters from any script, use Unicode property escapes:
/^\p{L}+$/u
The u flag is required for \p{...} to work at all. Be deliberate here, though: \p{L} is genuinely every letter in Unicode, which may be broader than a validator should accept. There is rarely a correct regex for human names; there is usually a correct decision to validate less.
6. The g flag makes the regex stateful
This one produces the strangest bug reports, because the pattern alternates between working and not working:
const re = /\d+/g;
re.test('42'); // true
re.test('42'); // false
A regex with g carries a lastIndex property. test and exec start from it and advance it, so calling twice on the same string resumes past the previous match, hits the end, and resets. If you reuse a global regex across calls — a module-level constant is the usual culprit — you get alternating results.
Fixes, in order of preference: do not use g when you only want a boolean; build the regex inside the function so each call gets a fresh one; or reset re.lastIndex = 0 before each use.
7. The pattern is fine but the input was not what you thought
Before rewriting a pattern for the fifth time, check the string itself. Text pasted from a PDF, a spreadsheet or a chat client routinely carries things that look like ordinary characters and are not: non-breaking spaces instead of spaces, curly quotes instead of straight ones, a zero-width space, a trailing \r from a Windows line ending.
\s does match a non-breaking space in JavaScript, but a literal " " in your pattern does not. Similarly " will never match ". If a pattern works in the tester and fails on real data, suspect the data.
A workflow that saves time
Build patterns outward from the smallest piece that works.
- Start with a literal fragment you know appears in the text. Confirm it matches.
- Replace one literal at a time with a class or a quantifier, checking after each change.
- Add anchors last. They are the most common cause of a pattern that suddenly matches nothing, and adding them last means you know exactly which change broke it.
Working this way, a failing regex tells you which of the seven you hit, because you only ever changed one thing. Paste your real input — not a simplified version of it — into the tester and you will usually find the answer in under a minute. If the text is coming out of an API response, format the JSON first so you can see the actual string value, escapes and all.
Regular expressions are not hard because the syntax is dense. They are hard because they do exactly what you say, and saying precisely what you mean is the difficult part of programming generally.