Search for an email regex and you will find the same handful of patterns copied across thousands of answers, usually with no discussion of what they do to real text. Most of them are validation patterns being used for extraction, which is a different job with different failure modes.
This guide is about extraction specifically: pulling addresses out of arbitrary text where they are surrounded by markup, punctuation and prose. It covers why the common patterns break, why boundary detection matters more than the middle of the pattern, and a worked approach with its limitations stated rather than glossed over.
Validation and extraction are different problems
Validation asks: is this entire string, which I already know is meant to be one address, well-formed? The input is bounded. It is anchored at both ends. A false positive means accepting a bad signup.
Extraction asks: within this arbitrary text, where do the addresses start and end? The input is unbounded and hostile. A false positive means importing junk into a CRM; a false negative means silently losing a contact you paid to acquire.
The consequence is that they want opposite trade-offs. A validator should be reasonably strict, because you can show the user an error. An extractor should be slightly permissive on the local part — a stray character attached to a real address is recoverable, an address you never matched is not — and strict on the domain, because a malformed domain has no server to ask.
Using a strict anchored validator for extraction is the single most common mistake, and it fails silently. You get results, they look fine, and you never learn about the addresses that did not match.
Why the popular pattern fails
The pattern you will meet most often, in some variation, is:
[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}It is a reasonable starting point and it is wrong in several specific ways.
The local part is missing legal characters. RFC 5322 permits ! # $ & ' * / = ? ^ ` { | } ~ unquoted, and the apostrophe in particular is not exotic — Irish and Italian surnames produce it constantly. A pattern without it silently drops john.o'brien@example.com.
Consecutive and leading dots are accepted. The character class permits a..b@example.com and .dana@example.com, neither of which is a valid address. Extracting them means bounces.
The domain permits nonsense. [a-zA-Z0-9.-]+ matches -acme-.io and acme..io. Hostname labels may not start or end with a hyphen, and may not be empty.
There is no boundary handling at all. This is the serious one, and it is the subject of the next two sections.
You will also see the "RFC 5322 compliant" monster pattern, several thousand characters long. It is a validator, it is unreadable, it is slow, and matching it still tells you nothing about whether the mailbox exists. It has no place in an extraction pipeline.
Boundaries are the hard part
In running text, an address is surrounded by other characters, and deciding where the match stops is harder than deciding whether the middle is well-formed.
Consider what the naive pattern does to these real inputs:
Contact:dana@acme.io— matchesContact:dana@acme.io? No, the colon is not in the class, so this one is fine. Butsee_also_dana@acme.iomatches entirely, because underscore is in the class, and the real address may have beendana@acme.io.<dana@acme.io>— the angle brackets are excluded, so this works. Good.mailto:dana@acme.io?subject=Hi— matchesdana@acme.ioand stops at the?. Correct by luck, since?is not in the domain class.Email dana@acme.io.— matchesdana@acme.io.with the sentence full stop attached, because.is in the domain class. This one is wrong, and it is extremely common.version1.2@3.4in a changelog — matches, and is not an address at all.
The fix for the left edge is a boundary assertion that prevents the match starting in the middle of a longer word. A plain \b is not sufficient, because the word characters it recognises do not include the punctuation legal in a local part. A negative lookbehind for the characters that could legitimately precede an address is more precise.
Trailing punctuation, and why it is ambiguous
The right edge is genuinely ambiguous, and it is worth being honest about that rather than pretending a pattern solves it.
In Write to dana@acme.io. the final dot is sentence punctuation. In dana@acme.io.uk — were such a domain to exist — a dot in the same position is part of the address. A regular expression cannot distinguish these from structure alone; it needs to know something about what follows.
The workable rule, and the one most competent extractors use: a domain may not end with a dot or a hyphen, so require the final label to be letters only and at least two of them, and refuse to include a trailing dot in the match. This resolves the common case correctly and costs nothing.
The same logic applies to closing brackets and quotes. (dana@acme.io), "dana@acme.io", [dana@acme.io] and dana@acme.io; all appear constantly in real sources, and none of those trailing characters belongs to the address. Excluding them from the domain character class handles all four.
One case that remains genuinely unresolvable: a trailing hyphen in dana@acme.io-see-below. Hyphens are legal inside domain labels, so there is no structural way to know the address ended at io. This is rare enough to accept.
A worked extraction pattern
Putting the above together, a pattern along these lines handles real text considerably better than the popular one:
(?<![A-Za-z0-9._%+-])[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}\bReading it in pieces:
(?<![A-Za-z0-9._%+-])— a negative lookbehind. Refuses to start the match if the preceding character could have been part of a longer local part. This is what stopssee_also_dana@acme.iofrom being matched from the wrong place.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+— one or more legal local-part characters, notably excluding the dot.(?:\.[...]+)*— zero or more dot-separated further chunks. Because each dot must be followed by at least one character, this structurally forbids leading, trailing and consecutive dots. That is cleaner than trying to exclude them afterwards.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+— one or more domain labels. Each starts and ends with an alphanumeric, may contain hyphens in the middle, and is capped at 63 characters as DNS requires.[A-Za-z]{2,63}— the final label, letters only. Two characters covers.ioand.ai; the upper bound accommodates long gTLDs like.international. Note that a length cap of four, which older patterns use, is now simply wrong.
Lookbehind is supported in modern JavaScript, Python, .NET, Java and PCRE. In an environment without it, capture a leading boundary group instead and discard it — more awkward, same result.
The trade-offs, stated plainly
No extraction pattern is complete, and a guide that does not say what its pattern misses is not being straight with you. This one deliberately does not handle:
- Quoted local parts.
"dana wright"@example.comis valid per RFC and essentially never occurs in real contact data. Supporting it means allowing spaces inside a match, which costs precision on every other input. - Non-ASCII local parts.
дана@example.comrequires the receiving server to support SMTPUTF8, and many sending platforms reject these at import. Adding Unicode ranges is a deliberate choice with real downstream consequences, not a free improvement. - IP-literal domains.
dana@[192.0.2.1]is valid and never legitimate in a contact list. - Punycode readability.
xn--mnchen-3ya.dematches fine as ASCII, but the display formmünchen.dedoes not. If your sources contain internationalised domains in display form, normalise before matching.
Each of these is a conscious decision to trade recall for precision on the inputs that actually occur. Whether that is right depends on your data — if you work with Cyrillic or CJK sources, the calculus changes.
What belongs in post-processing instead
A common failure is trying to encode every rule in the pattern. Some things are far cleaner afterwards, and a simpler pattern plus a filtering step beats one unreadable pattern.
Do these in code, not in regex: lowercasing; deduplication, including provider-specific rules such as ignoring dots at Gmail; role-account removal, which is a prefix set lookup; consumer domain filtering, which is a domain set lookup; disposable domain filtering; the 254-character total length limit and the 64-character local part limit; and stripping the query string from a mailto: link.
The general principle: use the pattern to find candidate spans, then apply business rules to the extracted strings. Rules expressed in code are testable, readable and modifiable by someone who is not fluent in regex — which, six months from now, includes you.
Performance and catastrophic backtracking
Extraction runs over large inputs, so the pattern must degrade gracefully. The danger is catastrophic backtracking: nested quantifiers that can match the same text in exponentially many ways, so an engine faced with a near-miss tries all of them. A pattern that handles a megabyte in milliseconds can hang for minutes on a crafted string, which is a denial-of-service vector if the input is user-supplied.
The classic trigger is a repeated group inside another repetition where the inner and outer parts can match the same characters — (a+)+ in miniature. The pattern above avoids it because each repetition is separated by a mandatory literal: the dot in the local part, the dot between domain labels. There is no ambiguity about which part of the input a given repetition consumed.
Two practical measures. Compile the pattern once rather than inside the loop — in JavaScript that means hoisting the literal out of the function, and in Python using re.compile. And chunk very large inputs rather than matching across the whole thing at once, splitting on line boundaries so no address is cut in half.
A test corpus worth keeping
Regex changes are exactly the kind of change that silently breaks a case you fixed months ago. Keep a fixture file, and add to it every time real data surprises you. A minimum set:
- Should match: plain
dana@acme.io; with a plus tag; with an apostrophe; with a subdomain; a long gTLD; a two-letter TLD; inside angle brackets; inside amailto:link with a query string; inside JSON quotes; inside an HTML attribute; at the very start and very end of the input. - Should not match: leading dot; trailing dot before the
@; consecutive dots; no@; two@; empty domain label; domain label starting or ending with a hyphen; single-character TLD; a bare hostname with no dot;dana [at] acme.io. - Should match, with the right boundary: followed by a full stop, comma, semicolon, closing bracket, closing quote or newline — asserting the exact matched string, not merely that something matched.
- Should be found in context: a mail log line, an email header block, a CSV row where one cell holds two addresses, a paragraph of prose.
That last group is the one people skip and the one that catches real regressions, because it tests the boundaries rather than the middle. If you write only one set of tests, write those.
For what the format actually permits underneath all this, see what actually counts as a valid email address; for the practical side of pulling addresses out of specific source types, see how to extract email addresses from any text, file or export.