Email Address Validation: RFC Rules vs Practical Regex
What RFC 5322 allows, what the browser's email regex accepts, and a five-step ladder from loose checks to confirmation emails, with tested JavaScript and Python.

Almost every sign-up form asks for an email address, and almost every developer has written a regular expression to check it. Most of those expressions are wrong in one of two directions. Some reject real people, such as the customer whose address contains a plus sign or whose domain ends in a long new top-level domain. Others accept strings that can never receive mail. Meanwhile the formal standard describes a grammar so permissive that following it to the letter is rarely what you want.
This guide is for developers who build forms, APIs and import pipelines, and for anyone who has ever been told "that is not a valid email" about an address they use every day. You will learn what an address is made of, what RFC 5322 and its relatives formally allow, what real mail systems accept, and why the browser's own check is deliberately stricter than the standard. You will also get a ladder of five validation strategies, tested JavaScript and Python code, a table of 40 sample addresses with the results I actually obtained, and a troubleshooting section for the complaints you will eventually receive.
What an email address is, and a short history
An email address has three parts: a local part, an @ sign, and a domain. In ada.lovelace@example.com, the local part is ada.lovelace and the domain is example.com. The domain tells the internet which servers accept mail. The local part is meaningful only to the server that owns the domain, which can interpret it however it likes.
That last sentence is the key idea behind everything else in this article. The domain part is governed by the global DNS. The local part is private to the receiving system. A mail host might treat Ada and ada as the same mailbox, or as different ones. It might ignore dots, support tags after a plus sign, or map the name to an internal alias. Nobody outside that host can know, so a validator that claims to know is guessing.
Where the rules came from
Electronic mail predates the web. The Wikipedia article on email addresses traces the user@host convention back to the early networked mail systems of the 1970s, and the formats were written down in a series of Internet standards, often called RFCs (Requests for Comments). Two documents matter most today, and they cover different layers.
- RFC 5322, the Internet Message Format, defines what a message looks like, including the grammar of addresses inside headers such as
From:andTo:. - RFC 5321, the Simple Mail Transfer Protocol, defines how servers pass mail to each other, and it sets the practical limits on the size of mailboxes and domains.
Both replaced earlier versions, and both are more permissive than most people expect. A third group of documents, the "email address internationalization" family that includes RFC 6531, extends the model so that the local part and domain can contain non-ASCII characters. I will refer to it at a conceptual level, because the details are long and the practical lessons are short.
One more thing is worth knowing before you read the grammar. These documents describe what mail software must be able to parse and transport. They do not say what a sign-up form should accept. Those are separate questions, and confusing them is the root of most bad validators.
Anatomy of an address, piece by piece
Let us take the parts one at a time and compare the formal rules with real-world behaviour.
The local part
Under the RFC 5322 grammar, the local part comes in two forms.
- Dot-atom. One or more "atoms" separated by single dots. An atom may contain letters, digits and these symbols:
! # $ % & ' * + - / = ? ^ _ ` { | } ~. A dot cannot be first, last or doubled. Sofirst.last,o'brienanda+bare valid, while.first,last.anda..bare not. - Quoted string. Any text inside double quotes, with a backslash to escape the quote or backslash characters.
"john doe"@example.comis formally valid, and so is"a@b"@example.com. Spaces, extra@signs and even doubled dots are allowed inside the quotes.
The older grammar also allowed a "obsolete" syntax with looser dot placement and embedded comments. A conforming receiver is expected to tolerate some of it for backward compatibility, but a conforming sender should not produce it. For a form on your website, you can ignore it.
RFC 5321 adds a length limit: the local part may not exceed 64 octets. That limit is on the SMTP side, and it is the reason very long local parts are rejected by real servers even when the 5322 grammar would allow them.
Plus-addressing and dots
Many providers support plus-addressing: ada+shopping@example.com is delivered to the mailbox for ada, and the +shopping tag helps the recipient filter mail. That is a provider convention, not a standard. The + is simply an ordinary allowed character, and whether it has special meaning depends on the receiving host.
Some providers also ignore dots in the local part, so a.d.a@example.com and ada@example.com reach the same inbox. Again, this is provider behaviour. Other hosts treat them as different mailboxes. The consequence for you is firm: never strip dots or tags from an address before storing it, because you would be merging mailboxes that might belong to different people.
Case sensitivity
RFC 5321 says the local part is technically case-sensitive, because the receiving host may choose to treat Ada and ada as different mailboxes. In practice almost every major provider treats them as the same, and users do not expect case to matter. The domain part is the opposite: DNS names are case-insensitive, so Example.COM and example.com always refer to the same place.
That asymmetry gives the safest normalisation rule: lowercase the domain, leave the local part alone. If you want a case-insensitive uniqueness check, you can compare lowercased copies for lookups while preserving the original spelling for sending. I come back to this in the normalisation section.
The domain
The domain in an ordinary address is a hostname made of labels separated by dots. Each label is 1 to 63 characters, and the classic rules, rooted in the older hostname standards, allow letters, digits and hyphens, with no hyphen at the start or end of a label. The whole name is limited to 253 characters in its usual text form (255 octets in the SMTP limit, including length bytes in the wire encoding, which is why you will see both numbers).
RFC 5322 also allows a domain to be written as a domain literal: an IP address in square brackets, such as user@[192.168.0.1] or user@[IPv6:2001:db8::1]. It is valid syntax and occasionally used for testing or in closed networks. No consumer sign-up form should accept it.
Two details surprise people.
- A domain with no dot such as
user@localhostoradmin@corpis syntactically valid. Inside a company network it may even work. On the public internet, top-level domains do not receive mail by themselves, so for a public sign-up form you can safely require a dot. The rule is a policy choice, not a syntax rule. - Long top-level domains. Years ago, validators assumed a TLD had two to four letters. Today there are many longer ones, such as
.technologyor.photography. Any pattern capped at four characters rejects real customers.
Comments and folding whitespace
RFC 5322 lets you put a comment in parentheses next to the address parts, so user(comment)@example.com is syntactically legal in a header. It also allows whitespace and line folding around certain tokens. These features exist for the message header format. They are not features that a person types into a field, and a form should not accept them.
Internationalised addresses
Two different problems hide under the word "international".
Internationalised domain names (IDN). A domain such as bücher.example contains non-ASCII characters. DNS itself only carries ASCII, so the name is converted with an algorithm called IDNA, and the result is a form that starts with xn--. The encoding used for the non-ASCII labels is Punycode. In my test run, Node's url.domainToASCII("bücher.example") and Python's "bücher.example".encode("idna") both produced xn--bcher-kva.example, and 例子.广告 became xn--fsqu00a.xn--4rr70v in both. See the IDN article on Wikipedia for background. A practical approach is to accept the Unicode form from users and convert the domain to its ASCII form before using it with DNS or older mail software.
Internationalised local parts. An address such as 用户@例子.广告 has non-ASCII text on the left of the @. This needs mail servers that support the SMTPUTF8 extension, specified in the RFC 6531 family. Support is much more common now than it once was, but it is not universal, so a message to such an address can fail at a server on the path that has not been upgraded. Whether you accept these is a product decision. If your users include communities that use them, reject them and you will be rejecting customers.
The overall length limit
You will often see 254 characters quoted as the maximum length of an address. It comes from the SMTP path limit of 256 octets, which includes the angle brackets that surround the address in the protocol, leaving 254 for the address itself. Treat this as a widely used practical maximum rather than as a line in the grammar. Combined with the 64-octet local part limit and the 253-character domain limit, it gives you three cheap checks you can run on any string.
Why a fully RFC-compliant regex is impractical
People have tried it. A regular expression that implements the full 5322 address grammar, including nested comments and quoted strings with every escape, runs to several thousand characters, and nested comments cannot be matched by a true regular expression at all, because comments can contain comments to arbitrary depth. A pure regex cannot count matching parentheses.
Even if you could build it, it would be the wrong tool, for four reasons.
- It accepts addresses you do not want.
"a b"@example.com,user(comment)@example.comanduser@[10.0.0.1]are valid by the grammar, but if you accept them into a mailing list, you create support issues and give attackers odd inputs to try against the rest of your stack. - It still does not prove the address works. Grammar says nothing about whether the domain exists or whether the mailbox does.
nobody@example.comis perfectly valid and perfectly useless. - It is hard to maintain and slow to run. Huge patterns with nested optional groups are the classic recipe for catastrophic backtracking, which I demonstrate with measured numbers below.
- The standard describes message headers, not form fields. The grammar is built for parsing a header that a mail server has received, not for judging a string a person typed into a box.
The HTML specification takes a pragmatic line. The WHATWG HTML Standard describes the pattern behind <input type="email"> as a deliberate departure from the RFC grammar, one that is stricter on some points and looser on others, chosen because it is a sensible match for what people and servers actually use. The MDN page on the email input documents the user-facing behaviour, and the HTML specification for input elements is the authoritative source for the exact pattern.
The browser's regex, explained
Here is the pattern that the HTML specification gives for a valid email address, written as a JavaScript regular expression with anchors added:
const PRACTICAL = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
It looks dense, but it breaks into four clear pieces.
1. The local part: [a-zA-Z0-9.!#$%&'*+\/=?^_{|}~-]+`
One or more characters from the allowed set: letters, digits, and the symbols that RFC 5322 permits in an atom, plus the dot. Notice what is missing: quotes, spaces, parentheses, commas and any non-ASCII letter. It also allows the dot anywhere, so this pattern does not enforce the "no leading, trailing or doubled dot" rule. That is one of the places it is looser than the RFC.
2. The separator: @
Exactly one, because the local-part class does not contain @, and neither does the domain pattern.
3. The first domain label: [a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
A label starts with a letter or digit, then optionally has up to 61 letters, digits or hyphens, and ends with a letter or digit. The shape guarantees a length of 1 to 63 characters and no hyphen at the start or end, which is the classic hostname rule.
4. More labels: (?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*
Zero or more additional labels, each preceded by a dot, with the same label shape. The * is why user@localhost passes: a single label is enough. The pattern does not require a dot in the domain.
What it deliberately leaves out
The browser pattern is an ASCII-only check. A domain like bücher.example fails it in its Unicode form, even though the punycode form xn--bcher-kva.example passes. It rejects quoted local parts, comments and IP-literal domains. It does not limit the total length of the local part or of the address, and it does not require a dot in the domain.
In short, it is a good first-line filter that catches typos and obvious junk without much risk of false alarms for ordinary Western-style addresses. It is not a verdict on whether mail can be delivered, and it will reject some addresses that the RFCs and modern servers accept. The browser also does some preparation before it checks the pattern. Per the specification, it strips leading and trailing whitespace and line breaks from the value, and the exact handling of internationalised input has varied between browsers, so test the behaviour in the browsers you support instead of assuming.
Two further notes about the browser field:
- Adding the
multipleattribute allows a comma-separated list, and each item is checked separately. - The
novalidateattribute on the form turns off the built-in check entirely, and any user can remove the attribute or send a request without using your form at all. Server-side validation is therefore not optional, a point I return to in the security section.
The validation ladder
The right amount of validation depends on what a wrong answer costs you. I think of it as a ladder with five rungs. Each rung catches a different kind of problem, costs more effort, and gives a stronger signal. You usually climb until the next rung costs more than the problem is worth.
| Rung | Check | Catches | Misses | Cost |
|---|---|---|---|---|
| 1 | Loose syntax | Empty fields, missing @, spaces |
Most typos | Trivial |
| 2 | Practical regex | Malformed domains, illegal characters | Typos in valid-looking text, dead domains | Low |
| 3 | DNS and MX lookup | Domains that do not exist or accept no mail | Wrong mailbox, disposable services | Medium, needs network |
| 4 | Disposable-domain list | Throwaway providers | New throwaway domains | Medium, list upkeep |
| 5 | Confirmation email | Everything: existence and ownership | Nothing, but users may not click | Higher, adds friction |
Rung 1: the loose check
The loosest sensible check asks only three questions. Is there exactly one @? Are both sides non-empty? Is the string free of whitespace? It will never reject a real address, which makes it a good default when false rejections cost more than bad data. It accepts plenty of nonsense, such as a@b and .@., and it wrongly rejects a quoted local part that contains @, because splitting on @ produces three parts. That is the rare case I am happy to reject.
Here is the version I tested:
function loose(s) {
if (/\s/.test(s)) return false;
const parts = s.split("@");
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
}
import re
def loose(s):
if re.search(r"\s", s):
return False
parts = s.split("@")
return len(parts) == 2 and bool(parts[0]) and bool(parts[1])
Rung 2: the practical regex
This is the browser pattern from the previous section. Use it on the server as well as the client, so that both agree. I also add a "strict" variant that layers on the extra rules a public sign-up form usually wants: the length limits, a dot in the domain, and no leading, trailing or doubled dots in the local part.
function practical(s) {
return PRACTICAL.test(s);
}
function strict(s) {
if (s.length > 254) return false;
const i = s.lastIndexOf("@");
if (i < 1 || i > 64) return false;
const domain = s.slice(i + 1);
if (!domain.includes(".") || domain.length > 253) return false;
return practical(s) && !/(^\.|\.\.|\.@)/.test(s);
}
import re
PRACTICAL = re.compile(
r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+"
r"@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
)
def practical(s):
return PRACTICAL.fullmatch(s) is not None
def strict(s):
if len(s) > 254:
return False
i = s.rfind("@")
if i < 1 or i > 64:
return False
domain = s[i + 1:]
if "." not in domain or len(domain) > 253:
return False
return practical(s) and not re.search(r"(^\.|\.\.|\.@)", s)
Notice that the Python version uses fullmatch and has no ^ or $. That choice is deliberate and I explain why in the pitfalls section. I ran both versions with Node 22 and Python 3.13 on the same list of 40 addresses and they agreed on every row.
Rung 3: DNS and MX checks
Once the syntax is plausible, you can ask the DNS whether the domain can receive mail. A domain with an MX record names the mail servers that accept its mail. If there is no MX record, the SMTP rules fall back to the domain's address record, so a missing MX is not conclusive on its own. A domain that does not resolve at all (the DNS answer is "no such name") is a strong sign of a typo, such as gmial.com if it happened not to exist.
The cost is a network call at validation time, which brings latency, timeouts and failure modes. Do the lookup on the server, set a short timeout, cache results, and fail open when DNS is slow or unavailable, because a temporary DNS problem should not block a sign-up. Treat the result as a hint. A domain can have valid records and still have no mailbox for this user, and a corporate domain behind a strict gateway may reject test connections.
Avoid the older technique of connecting to the mail server and asking whether the mailbox exists (an SMTP "RCPT TO" probe). Many servers refuse to answer honestly to prevent harvesting, some treat the probe as abuse, and your IP address can end up on blocklists.
Rung 4: disposable-domain lists
Disposable email services give users a temporary inbox that exists for minutes. If your product depends on a long-term relationship, you may want to block them, or at least flag the account. The mechanism is a lookup of the domain against a maintained list.
This works only partially. New disposable domains appear constantly, and a list is always behind. It can also block legitimate privacy-minded users, because some privacy services that forward mail are not the same as throwaway inboxes, though lists sometimes lump them together. Decide what you really want to prevent, show a polite message that offers another way forward, and keep the list updated from a source you trust.
Rung 5: confirm by sending mail
Only one test proves two things at once: the address exists, and the person typing it controls it. Send a message with a single-use link or short code, and activate the account only after they use it. Nothing on the lower rungs can tell you that.
Make it work well:
- Use a long, random, single-use token that expires after a sensible time, and store only a hash of it.
- State on screen which address you sent mail to, and offer "change address" and "resend" options.
- Keep the message short, with one clear action, and send it from a domain with proper authentication records so it does not land in spam.
- Do not let an unconfirmed address receive marketing mail, and do not let it be used for password resets until it is confirmed.
Confirmation is the only rung that is also a security control, since it blocks people from signing up with someone else's address.
Client-side versus server-side validation
Both have jobs, and neither replaces the other.
Client-side validation is for the user's benefit. It gives instant feedback, saves a round trip, and can offer typo suggestions. Use the built-in type="email" and constraint validation first, and add JavaScript only where it improves the message. But it is advisory: anyone can disable JavaScript, edit the DOM or call your API directly.
Server-side validation is for your system's benefit. It is the only one you can trust. Run the same practical check on the server, enforce the length limits, normalise the value, and handle the result of the confirmation step. Keep the client and server rules identical, or have the client rule be looser, so that the server never rejects something the form has already told the user is fine. If they disagree, the user sees a confusing error after a form that said all was well.
A good arrangement for most products is: browser check for feedback, the same practical check on the server for safety, and a confirmation email for truth.
Normalising, storing and comparing
Validation says "this looks like an address". Normalisation decides what you store. The rules are simple, and getting them wrong causes hard-to-find bugs.
- Trim leading and trailing whitespace. Mobile keyboards and copy-paste add stray spaces constantly.
- Lowercase the domain. DNS names are case-insensitive, so this is always safe.
- Do not alter the local part. Do not lowercase it, strip dots, remove
+tags, or convert characters. You cannot know the rules of the receiving host. Keep what the user typed, and use it as the delivery address. - Convert an internationalised domain to its ASCII form for DNS lookups, but keep the original form for display, if you accept Unicode at all.
- Check length limits after trimming: 64 for the local part, 253 for the domain, 254 overall.
In code:
function normalise(s) {
s = s.trim();
const i = s.lastIndexOf("@");
if (i < 0) return s;
return s.slice(0, i) + "@" + s.slice(i + 1).toLowerCase();
}
// normalise(" Ann.Lee+News@Example.COM ") -> "Ann.Lee+News@example.com"
def normalise(s):
s = s.strip()
i = s.rfind("@")
return s if i < 0 else s[:i] + "@" + s[i + 1:].lower()
Both printed Ann.Lee+News@example.com for the input " Ann.Lee+News@Example.COM " in my run.
Uniqueness and duplicates
Many apps need "one account per email". A pragmatic approach is to store the address as the user typed it (with the domain lowercased) and add a second, lookup key that is the whole address lowercased. Put the unique constraint on the lookup key. That stops Ann@example.com and ann@example.com from creating two accounts at most providers, without rewriting what you send mail to.
Be careful before going further and treating a.nn@gmail-style-provider.com and ann@... as the same, or removing +tags. It is tempting as an anti-abuse measure, but it can merge two real people and lock one of them out. If abuse of plus-tags is a real problem for you, handle it as a risk signal and not as an identity rule.
Real-world use cases
Sign-up and login forms. The most common case. Use the browser check, a server check and a confirmation email. Show the address back to the user so they can spot typos.
Newsletter and marketing sign-ups. Double opt-in is the same confirmation rung, and it also protects you from people who submit someone else's address. It protects your sender reputation too, because mail to dead addresses harms deliverability.
Bulk import and CRM clean-up. When you load thousands of addresses from a spreadsheet, a loose or practical check flags the obviously broken rows, and you report them to a human instead of silently dropping them. Trim and normalise first. Many failures turn out to be trailing spaces, line breaks or a stray semicolon.
Contact forms. The address is there so you can reply. A practical check plus the honeypot or rate limit you already use against spam is usually enough. A confirmation email would be overkill.
Internal tools and admin screens. Here the addresses come from a directory you control, so validate for typos and move on.
APIs and integrations. Validate in the API, not only in the front end. Return a clear machine-readable error with the field name, so client developers can show the right message.
Parsing mail headers. If you read From: and To: headers in a mail-processing application, use the parsing library that comes with your language or framework, because headers involve display names, comments and folding that a form regex does not handle. In Python, the standard library's email.utils.parseaddr is one example. Libraries like these parse a header, but they do not decide whether a string should be trusted as a sign-up address.
If you generate or clean data in other formats at the same time, you may find the JSON Formatting Guide handy when your validation errors come back as JSON from an API.
Common mistakes and pitfalls
Rejecting valid addresses
- Rejecting
+. The plus sign is allowed, and people use it for filtering. A pattern that allows only letters, digits, dots, underscores and hyphens rejects them. - Capping the TLD at 2 to 4 letters. This was never correct, and is now plainly wrong.
- Requiring lowercase. Users type capitals. Normalise the domain, but do not reject.
- Rejecting apostrophes.
o'brien@example.comis valid, and real. - Rejecting non-ASCII without a plan. If you reject it, say why, and consider accepting the punycode form.
- Forbidding a subdomain or a long chain of subdomains.
user@mail.dept.example.co.ukis ordinary.
Accepting bad input
- Trusting the browser alone. Server-side checks are mandatory.
- Accepting newlines. An address that contains a carriage return and line feed can be used for header injection, covered below.
- Believing a regex means the address exists. It only means the text has the right shape.
- Treating a missing MX as proof of a typo. The address record fallback exists.
Regex-specific traps
The Python $ trap. In Python, $ matches at the end of the string and also just before a trailing newline. So re.match(r"^\S+@\S+$", "a@b.co\n") succeeds. In my test it returned True, while re.fullmatch(r"\S+@\S+", "a@b.co\n") returned False. This is why my Python code uses fullmatch and no anchors. In JavaScript, $ without the multiline flag matches only at the very end, so the same input is rejected, but make sure you do not turn on the m flag, where ^ and $ match at every line boundary.
Forgetting to anchor. Without ^ and $ (or fullmatch), a pattern finds an address anywhere inside a longer string, so hello user@example.com goodbye would pass.
Too clever a pattern. Every extra optional group gives a regex more ways to match the same text, and that matters when the input is hostile.
Escaping errors. A character class like [a-zA-Z0-9.!#$%&'*+/=?^_{|}~-]` has a hyphen that must be last or escaped. A hyphen in the wrong place becomes a range and silently accepts or rejects the wrong things.
Over-processing
- Lowercasing the local part before sending mail.
- Removing dots or
+tagsto "clean up". - Using the lowercased copy as the delivery address.
Step-by-step: test addresses with DevUtilX
Reading about edge cases is one thing, but trying them is better. Two DevUtilX tools help, and both run in your browser, so the addresses you type are processed locally and are not uploaded.
Check an address with the Email Validator
- Open the DevUtilX Email Validator.
- Type or paste an address, for example
first.last+news@example.com. - Read the result. The tool tells you whether the address passes and what part failed when it does not.
- Try the edge cases from the table in this article: a plus sign, an apostrophe, a long TLD, a missing dot in the domain, a doubled dot in the local part.
- Compare the verdicts with the table, and note where a stricter or looser rule would change the result.
Because nothing leaves your browser, you can paste real customer addresses from a support ticket to see why a form rejected them.
Build and debug a pattern with the Regex Tester
- Open the DevUtilX Regex Tester.
- Paste the practical pattern from this article as the expression, without the surrounding slashes if the tool asks for the body only, and choose no flags.
- Paste a column of sample addresses, one per line, as the test text. If the tool treats the text as one string, turn on the multiline flag so
^and$apply per line, and remember that this flag is only for testing a list. - Look at which lines match. Change one character class at a time and watch the matches change.
- To see why a pattern can be dangerous, try a short test string with many repeated letters followed by an illegal character against a pattern with nested repeats, and keep the length small. If the tool slows down, you have found a backtracking problem.
When your pattern behaves, copy it into your code, and run the table from this article as a unit test. If you want to read about the habits that keep expressions maintainable, the same discipline applies to code in general, as covered in the guide to JavaScript code formatting best practices.
Test table: 40 addresses, four verdicts
I ran the JavaScript and Python implementations above on these 40 strings. Both gave identical results on every row. The "RFC syntax" column is my reading of the grammar, hedged where noted, and not output from a program. The other columns are actual results from the sandbox, where "strict" means the practical pattern plus the length, dot-in-domain and dot-placement checks.
| # | Address | RFC syntax | Loose | Practical | Strict |
|---|---|---|---|---|---|
| 1 | user@example.com |
yes | pass | pass | pass |
| 2 | first.last@example.com |
yes | pass | pass | pass |
| 3 | user+tag@example.com |
yes | pass | pass | pass |
| 4 | user_name@example.com |
yes | pass | pass | pass |
| 5 | o'brien@example.com |
yes | pass | pass | pass |
| 6 | a@b.co |
yes | pass | pass | pass |
| 7 | user@localhost |
yes | pass | pass | fail |
| 8 | user@sub.domain.example.org |
yes | pass | pass | pass |
| 9 | user@example.technology |
yes | pass | pass | pass |
| 10 | user@xn--bcher-kva.example |
yes | pass | pass | pass |
| 11 | very.long.local.part.with.numbers123@example.com |
yes | pass | pass | pass |
| 12 | user@example |
yes | pass | pass | fail |
| 13 | plainaddress |
no | fail | fail | fail |
| 14 | @example.com |
no | fail | fail | fail |
| 15 | user@ |
no | fail | fail | fail |
| 16 | user@@example.com |
no | fail | fail | fail |
| 17 | user name@example.com |
no | fail | fail | fail |
| 18 | user@exa mple.com |
no | fail | fail | fail |
| 19 | .user@example.com |
no | pass | pass | fail |
| 20 | user.@example.com |
no | pass | pass | fail |
| 21 | us..er@example.com |
no | pass | pass | fail |
| 22 | user@-example.com |
syntax yes, not a valid hostname | pass | fail | fail |
| 23 | user@example-.com |
syntax yes, not a valid hostname | pass | fail | fail |
| 24 | user@example..com |
no | pass | fail | fail |
| 25 | user@.example.com |
no | pass | fail | fail |
| 26 | "john doe"@example.com |
yes (quoted) | fail | fail | fail |
| 27 | "a@b"@example.com |
yes (quoted) | fail | fail | fail |
| 28 | user(comment)@example.com |
yes (comment, header syntax) | pass | fail | fail |
| 29 | user@[192.168.0.1] |
yes (domain literal) | pass | fail | fail |
| 30 | user@[IPv6:2001:db8::1] |
yes (domain literal) | pass | fail | fail |
| 31 | üser@example.com |
only with internationalisation extensions | pass | fail | fail |
| 32 | user@bücher.example |
only with IDN handling | pass | fail | fail |
| 33 | 用户@例子.广告 |
only with internationalisation extensions | pass | fail | fail |
| 34 | user@example.com followed by a newline |
no | fail | fail | fail |
| 35 | user@example.com then CRLF and Bcc: x@evil.test |
no | fail | fail | fail |
| 36 | a@b |
yes | pass | pass | fail |
| 37 | user@123.123.123.123 |
yes (as a dotted name) | pass | pass | pass |
| 38 | Abc@Example.COM |
yes | pass | pass | pass |
| 39 | user@exam_ple.com |
syntax yes, not a valid hostname | pass | fail | fail |
| 40 | USER@EXAMPLE.COM |
yes | pass | pass | pass |
Two details need explaining. Row 29 and 30 passed the loose check only because the loose check has no opinion on bracketed domains. Rows 34 and 35 failed the loose check because it rejects any whitespace, and a newline counts.
Where the practical regex disagrees with the RFC
Reading the table honestly, the practical pattern disagrees with the RFC grammar in these ways.
It accepts some things the grammar forbids.
- Leading, trailing and doubled dots in the local part (rows 19, 20, 21). The grammar says no. The browser pattern allows them. My strict variant adds a check for these.
It rejects some things the grammar allows.
- Quoted local parts (rows 26, 27).
- Comments (row 28), which belong to header syntax.
- IP-literal domains (rows 29, 30).
- Non-ASCII local parts and domains (rows 31, 32, 33), which need the internationalisation extensions in any case.
It rejects things the grammar's letter allows but real hostnames do not.
- Labels starting or ending with a hyphen (rows 22, 23) and underscores in the domain (row 39). Strictly, the 5322 grammar allows these characters in an atom, but a domain you can resolve follows the hostname rules, so rejecting them is right in practice.
It accepts some things that are legal but not useful for a public form.
- Single-label domains such as
user@localhostanduser@example(rows 7, 12, 36). My strict variant rejects them.
Is any of this a bug? I would say no. The pattern was designed as a practical check, and every disagreement lines up with a case that almost never arrives from a real user. The exceptions are the internationalised addresses, where the right choice depends on your audience.
Security considerations
Header injection
Many applications put the user's address into an email header, for example as a Reply-To: value or in a "send me a copy" feature. If an attacker can include a carriage return and line feed in that value, they can end the header and add their own, such as a hidden Bcc: recipient, or even change the body. This is called header injection (or CRLF injection), and it turns your contact form into a spam relay.
The defence has several layers:
- Reject any control character, especially CR and LF, in the address. The patterns in this article do, and rows 34 and 35 of the table show it.
- Use a mail library that takes the address as a separate parameter and handles encoding, and do not build raw headers by joining strings.
- Make sure your validator is anchored correctly. The Python
$trap above would leta@b.co\nthrough.
Catastrophic backtracking (ReDoS)
Regular expression engines that backtrack, including the ones in JavaScript and Python, can take exponential time on certain patterns and inputs. This is known as ReDoS (regular-expression denial of service), described in the Wikipedia article on ReDoS. The trigger is nested repetition where the same text can be split in many ways, and the input then fails at the very end, forcing the engine to try every split.
Here is a classic shape I wrote for the test, an "email-looking" pattern with a nested quantifier:
const bad = /^([a-zA-Z0-9]+\.?)+@example\.com$/;
The group ([a-zA-Z0-9]+\.?)+ can split a run of letters in a huge number of ways, because the dot is optional. I measured it in Node 22 on a short input of N letters a followed by !, so the match must fail. The times are from one run on a small sandbox machine, and they will differ on yours, but the shape is the point:
| N | Time for the nested pattern |
|---|---|
| 22 | about 0.1 ms |
| 24 | about 0.3 ms |
| 26 | about 1 ms |
| 28 | about 4 ms |
| 30 | about 30 ms |
Each two extra characters multiplied the time by roughly four, which means it doubles with every extra character. Extending the same trend, a string of 40 to 50 characters would take far too long to be safe, and I did not run it. Compare the practical pattern: on the same style of input, 26 letters plus ! took under a tenth of a millisecond, and 5,000 letters plus ! also took well under a millisecond. The practical pattern has no nested repeats over the same characters, so it fails fast.
Defences:
- Prefer simple patterns with no nested quantifiers on overlapping character sets.
- Check length first. Reject anything over 254 characters before running any regex. That caps the damage from any pattern.
- Use engines with guaranteed linear time for untrusted input, where your platform offers one.
- Set timeouts or run risky matching in a worker.
- Test patterns with failing inputs, not only passing ones. The problem only shows when the match fails.
Enumeration and information leaks
If your sign-up or reset forms say "this email is already registered" or "no account with this email", an attacker can test addresses one by one to find out who uses your service. Where this matters, respond with the same message either way ("if an account exists, we have sent a message") and send the real information by email. Add rate limits to the endpoint as well.
Never trust client-side checks
As covered above, the browser check is a convenience. Re-validate on the server, treat all fields as untrusted input, and encode the address properly where you display it, to avoid cross-site scripting. A string such as "<script>"@example.com is valid under the quoted-string grammar, which is another reason to refuse quoted local parts in forms, and a reason to escape output in any case.
Privacy and data protection
An email address is usually personal data, and often identifies a person directly. In many regions, privacy law such as the EU's GDPR applies, though I am not giving legal advice and the details depend on where you operate. At a high level, good habits are: collect only what you need, tell people why you want it and how you will use it, keep it only as long as necessary, protect it in storage, avoid writing full addresses into logs, and make it possible to delete or export it. Pasting real customer addresses into any online tool is a data-handling decision. The DevUtilX tools run locally in your browser, but you should still follow your organisation's rules for personal data.
Typos, usability and error messages
Syntax validation catches almost none of the mistakes that matter most. The common errors are typos inside valid-looking addresses: gmial.com, a missing letter in .com, a swapped pair of characters. Your options, from least to most helpful, are:
- Show the address back on the confirmation screen, so that people can see their own typo.
- Ask twice is a pattern that adds friction and that many people paste into the second box anyway, so it rarely helps much. I prefer showing the address back.
- Suggest a correction for common provider misspellings. Offer it as a question ("Did you mean ...?") that the user can accept or ignore, never as an automatic change.
- Use a confirmation email, which catches every typo that matters.
Writing good error messages
A message should say what is wrong and how to fix it, in plain words, next to the field.
| Weak | Better |
|---|---|
| "Invalid email" | "Enter an email address in the format name@example.com" |
| "Error" | "The address needs an @ sign and a domain, like example.com" |
| "Bad input!" | "Remove the spaces from the address" |
| "Domain not allowed" | "We cannot accept temporary email services. Please use your regular address." |
Do not blame the user, and do not display a regular expression. If you reject something that might be valid, such as a non-ASCII address, say so honestly and offer a path forward, such as contacting support.
Accessibility of form errors
Good validation is also an accessibility matter, because a screen-reader user needs to learn about errors without searching for them.
- Use a real
<label>connected to the input. Do not use the placeholder as the only label. - Use
type="email"andautocomplete="email"so that browsers and assistive technology offer the right keyboard and autofill. - Put the error message in the page text, next to the field, and link it to the input with
aria-describedby. Setaria-invalid="true"on the field while it is invalid. - Announce the error. Either move focus to the first invalid field on submit, or show a summary at the top with links to each field. A polite live region can announce errors that appear without a page reload.
- Do not rely on colour alone. Pair a red border with text and an icon.
- Validate at a sensible moment. Checking on every keystroke shouts "invalid" at someone who has typed half an address. Wait until the field loses focus or the form is submitted, then re-check as the user corrects.
A minimal pattern:
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email"
aria-describedby="email-error" required>
<p id="email-error" role="alert" hidden>
Enter an email address in the format name@example.com
</p>
For readable markup in forms like this one, see the guide on HTML formatting and semantic markup.
Performance considerations
Validation cost is rarely a problem, but a few points are worth knowing.
- A well-formed regex is cheap. The practical pattern ran in a fraction of a millisecond on my samples, even for a 5,000 character failing input. Do not optimise it.
- Check length first. It is the cheapest test and it protects every later step.
- DNS lookups are the expensive rung. Run them on the server, off the request's critical path if you can, with timeouts and caching.
- Do not validate on every keystroke on the server. Debounce, or validate on blur and submit.
- Batch imports. Compile the pattern once and reuse it. In Python,
re.compileat module level and in JavaScript a constant outside the function.
Troubleshooting guide
When someone reports a problem with an email field, work from the symptom to the cause.
Symptom: a valid address with a plus sign is rejected
Cause. The pattern's local-part class omits +, or the sign-up flow URL-decodes the value and turns + into a space before validation.
Fix. Allow + in the class. If the address travels through a query string, encode it with encodeURIComponent on the way out, because a raw + in a query string is read as a space by form-decoding rules.
Symptom: addresses at new or long TLDs are rejected
Cause. An old pattern limits the TLD to 2 to 4 letters, or checks against a hard-coded list of TLDs that went stale.
Fix. Use label-shaped rules (letters, digits, hyphens, 1 to 63 characters) and stop listing TLDs. Let DNS or the confirmation email decide whether the domain is real.
Symptom: an address with accented or non-Latin characters is rejected
Cause. The pattern is ASCII-only, as the browser one is.
Fix. Decide your policy. To accept internationalised domains, convert the domain to its ASCII form with an IDNA library before validating, and store both forms. To accept internationalised local parts, check that your outbound mail provider supports the SMTPUTF8 extension. If you cannot support them, tell the user which address types you accept.
Symptom: the form accepts user@example with no dot
Cause. The browser pattern allows single-label domains, because they are valid syntax.
Fix. If your service is public-facing, add the dot-in-domain rule, as the strict variant does, or leave it to the confirmation email.
Symptom: validation passes in the browser but the server rejects it
Cause. The client and server use different patterns, or the server sees the string after trimming or encoding changes.
Fix. Share one definition, with a test suite that runs on both sides. Log the exact string, with control characters made visible, to find out what changed.
Symptom: a trailing newline or space makes the address fail, or worse, pass
Cause. Pasted text carries a line break. In Python, $ lets a trailing newline through.
Fix. Trim before validating and use fullmatch in Python.
Symptom: the server CPU spikes when someone submits a long address
Cause. A regex with nested quantifiers is backtracking on hostile input.
Fix. Check the length first, replace the pattern with a simple one, and test with long failing strings.
Symptom: valid addresses bounce after sign-up
Cause. The syntax was fine but the mailbox does not exist, the domain refuses mail, or the user made a typo.
Fix. Add the confirmation email, handle bounces, and stop mailing addresses that bounce permanently.
Symptom: users register the same mailbox twice
Cause. Case or tag variants stored as separate accounts.
Fix. Add a lowercased lookup key with a unique constraint, as described above, and consider showing a "you may already have an account" hint.
Symptom: the confirmation email never arrives
Cause. The message landed in spam, the sending domain lacks proper authentication records, or the address was a typo that happened to be valid.
Fix. Check your sender configuration, give the user a "resend" button and a way to change the address, and log delivery results.
Best practices checklist
- Start from the user's goal. A contact form, a newsletter and a bank sign-up need different rungs.
- Use
type="email"with a label andautocomplete="email", and keep the built-in validation for feedback. - Validate again on the server with the same practical pattern. Never trust the client.
- Check length first: 64 for the local part, 253 for the domain, 254 overall.
- Trim, lowercase the domain, leave the local part alone.
- Do not try to write a full RFC 5322 regex. Aim for a practical check that is easy to read.
- Keep patterns simple and free of nested quantifiers, and test them with long failing inputs.
- Anchor correctly, and use
fullmatchin Python. - Reject control characters, especially CR and LF, before the address goes near a header.
- Decide your international policy deliberately, and document it.
- Confirm by sending mail for any account that matters.
- Handle DNS checks and disposable lists as hints, and fail open on network errors.
- Write helpful, accessible error messages, linked to the field.
- Avoid leaking whether an address is registered, and rate-limit the endpoint.
- Treat addresses as personal data: collect less, log less, delete on request.
- Turn the test table into automated tests, so that a pattern change cannot quietly start rejecting real users.
Comparison: ways to validate an email address
| Approach | Strength of the signal | Effort | Main risk | Best for |
|---|---|---|---|---|
Loose check (one @, non-empty parts) |
Very weak | Very low | Accepts junk | Low-stakes forms, imports |
Browser type="email" |
Weak to moderate | None | Client-only, ASCII-only | Instant feedback |
| Practical regex on the server | Moderate | Low | Rejects rare valid forms | Most sign-ups |
| Full RFC 5322 grammar parser | Syntax only, complete | High | Accepts odd forms nobody wants | Mail-processing software |
| DNS and MX lookup | Moderate | Medium | Latency, false negatives | Catching dead domains |
| Disposable-domain list | Narrow | Medium | Stale list, blocks legitimate users | Anti-abuse |
| SMTP mailbox probing | Unreliable | High | Blocklisting, misleading answers | Generally avoid |
| Confirmation email | Strong, proves ownership | Medium | Drop-off from friction | Accounts, subscriptions, resets |
No row replaces all the others. The usual strong combination is the practical regex for shape, then the confirmation email for truth.
Putting it together: a small validator you can reuse
Here is the whole flow in one function, in Python. It normalises, validates and returns either a cleaned address or a reason. The messages are written for users, and the function is the shape I would put at an API boundary.
def check_email(raw):
s = normalise(raw)
if not s:
return None, "Enter your email address."
if len(s) > 254:
return None, "That address is too long."
if re.search(r"[\x00-\x1f\x7f]", s):
return None, "The address contains characters we cannot accept."
if not loose(s):
return None, "The address needs one @ sign with text on both sides."
if not strict(s):
return None, "Enter an address in the format name@example.com."
return s, None
It is deliberately layered. The cheap checks go first, so the expensive ones never see hostile input, and each failure produces a message that tells the user what to change. Pair it with the confirmation email, and you have covered rungs 1, 2 and 5 of the ladder.
If your API returns these results as JSON, keep the error structure consistent and compact, and see the notes on JSON minification and payload size for how responses behave on the wire.
FAQ
What is the simplest correct email validation?
Check that the string has exactly one @, with non-empty text on both sides and no whitespace, then send a confirmation email. The email is the only check that proves anything.
Is there a regex that validates every legal email address?
Not a practical one. The full RFC 5322 grammar includes nested comments, which a true regular expression cannot match, and even a partial implementation is huge and slow. Use a short practical pattern and confirmation instead.
Is user+tag@example.com valid?
Yes. The plus sign is an ordinary allowed character in the local part. Many providers deliver it to the mailbox before the plus sign, but that is provider behaviour, not part of the standard.
Are email addresses case-sensitive?
The domain is not. The local part is technically case-sensitive under the SMTP specification, but nearly all providers ignore case. The safe rule is to lowercase the domain and keep the local part exactly as typed.
What is the maximum length of an email address?
Commonly 254 characters overall, with at most 64 octets in the local part and 253 characters in the domain. The 254 figure follows from the SMTP path limit, so treat it as the widely used practical limit.
Why does user@localhost pass the browser check?
Because it is syntactically valid, and the browser pattern requires only one domain label. If you want a public-facing rule that requires a dot, add it yourself, as the strict variant in this article does.
Can an email address contain non-English characters?
Yes, with the right support. Internationalised domain names are converted to ASCII with Punycode, and internationalised local parts need mail servers that support the SMTPUTF8 extension. The browser pattern in this article rejects them in their Unicode form.
Does a valid MX record prove the address works?
No. It shows that the domain accepts mail, not that this mailbox exists. A missing MX record is also not conclusive, because the specification falls back to the domain's address record.
Should I block disposable email addresses?
Only if your product needs long-term contact. Lists lag behind new services and can catch legitimate users, so use a polite message and treat the list as a signal.
Is it safe to test real addresses in an online tool?
Only in a tool that processes input locally. The DevUtilX tools run in your browser, but follow your organisation's rules for personal data regardless.
Why does my Python regex accept an address with a trailing newline?
Because $ in Python matches just before a final newline. Use re.fullmatch, or \Z, and trim input first.
Do I need to validate on both the client and the server?
Yes. The client check is for fast feedback, and the server check is for safety, because anyone can bypass the browser.
How do I handle addresses with a quoted local part?
Most forms should reject them with a clear message. They are valid by the grammar, but rare, hard to validate and a source of odd input. If you must accept them, use a proper parsing library and test output encoding carefully.
Conclusion
Email validation is a ladder, not a single test. The RFCs describe a very permissive grammar for transporting mail, the browser uses a pragmatic subset for forms, and neither can tell you whether a mailbox exists or whether the person typing it owns it. Use a short practical pattern for shape, lowercase only the domain, guard against header injection and slow regexes, write helpful accessible errors, and confirm by email whenever the address matters.
To try the edge cases yourself, open the Email Validator and run the sample addresses from this guide, then refine your own pattern in the Regex Tester. For related reading, see our guides on JavaScript code formatting best practices and HTML formatting and semantic markup.
Further reading
Try the tools
Quickly check if an email address is properly formatted and valid. This tool is essential for developers and businesses to ensure the accuracy of user-provided email addresses, reducing bounce rates and improving data quality.
Test your regular expressions against sample strings to ensure they work as expected. This tool is essential for developers who need to validate patterns or extract data from text, providing real-time feedback and debugging.

