Regex for Email Validation: Patterns, Edge Cases, and the Mistakes That Slip Through
Validating an email with regex is one of those tasks that looks trivial and then eats an afternoon. The truth: a regex can reject what you don't want and still let garbage through, because the full RFC 5322 grammar is a monster. This guide gives you the pattern worth using, the edge cases that matter, and the line you should not cross.
The pragmatic pattern
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; This rejects obvious garbage (no @, no dot, spaces) and accepts valid addresses that longer patterns often break. It is deliberately loose. If you want slightly stricter local-part rules, extend the first class but keep the domain part simple:
const STRICTER = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; 
Edge cases naive regexes break
Three cases break most hand-rolled patterns. First, the + in the local part ([email protected]) — valid, used everywhere, killed by patterns that whitelist only letters and digits. Second, subdomains and long TLDs ([email protected]) — fine for the pragmatic pattern, broken by \.[a-z]{2,3}$ hardcodes. Third, single-character local parts and unusual but valid domains: the pragmatic pattern passes all of these, which is what you want at the form level.
What regex cannot do
Regex proves format, not existence. [email protected] passes every pattern and is not a mailbox anyone checks. The only reliable validation is sending a confirmation email. At form time, use the loose pattern plus a domain-has-dot check; at signup time, send the verification link. Never use regex as the final authority.
ReDoS and performance
Catastrophic backtracking is real in email regexes. Anything with nested quantifiers like ([a-z]+)+ can stall on long hostile input. The pragmatic pattern above has no nested quantifiers, which is another reason to prefer it. If you accept user input server-side, always bound the input length before matching (if (email.length > 254) reject).
Quick test harness
const cases = [
"[email protected]", "[email protected]", "[email protected]", // should pass
"plainaddress", "user@example", "user [email protected]", // should fail
"user@@example.com", "@example.com", "[email protected]", // should fail
];
for (const c of cases) console.log(c, EMAIL_RE.test(c) ? "PASS" : "FAIL"); Run that, and you will see why the pragmatic pattern is the right default: it catches the obvious failures without inventing false rejections.