Validating Email Addresses in Python: Syntax, MX and SMTP
To validate an email address in Python, call validate_email() from the email-validator package on the stripped input. It checks syntax, normalises the address and looks up MX records. Handle its DNS timeout case explicitly, because it fails open. Use dnspython for custom DNS logic and smtplib only if you need a mailbox-level check.
Everything here was run on 1 October 2026 with Python 3.14.5, email-validator 2.3.0 and dnspython 2.8.0. The outputs shown are what we got.
What not to use: regex and parseaddr
Neither a regex nor email.utils.parseaddr validates an address; one checks rough shape, the other parses mail headers.
We covered regex in detail in why email validation regex fails: five widely copied patterns, none scoring better than 15 of 23 test cases, one taking 4.8 seconds on a 33-character input. Python's re engine backtracks too, so the same patterns carry the same risk.
parseaddr is the more surprising trap, because it lives in the standard library's email package and looks authoritative:
from email.utils import parseaddr
parseaddr("jane@example.com") # ('', 'jane@example.com')
parseaddr("not an email") # ('', 'not')
parseaddr("john..doe@example.com") # ('', 'john..doe@example.com')
parseaddr("jane@@example.com") # ('', '')
It returned 'not' for "not an email". Its job is splitting Jane <jane@example.com> into a name and an address, not deciding whether the address is legal.
The library to use: email-validator
email-validator checks syntax against the RFCs, returns a normalised address, and checks the domain's MX records unless you turn that off.
pip install email-validator
Its README describes the DNS check precisely: it looks for "a DNS MX record indicating that it can receive email. (Except a Null MX record. If there is no MX record, a fallback A/AAAA-record is permitted, unless a reject-all SPF record is present.)" That is a more careful reading of RFC 5321 and RFC 7505 than most hand-rolled code manages.
It raises EmailSyntaxError for bad syntax and EmailUndeliverableError for failed DNS checks, both subclasses of EmailNotValidError.
Here is how it handled our test inputs with default settings:
| Input | Result |
|---|---|
o'brien@example.ie |
valid |
john..doe@example.com |
"An email address cannot have two periods in a row." |
jane@example.com |
"The domain name example.com does not accept email." (null MX) |
jane@nonexistent-domain-sv-test-9a8b7c.com |
"The domain name ... does not exist." |
jane@gmial.com |
valid, MX falls back to the A record |
jane@localhost |
rejected, no period in the domain |
"john doe"@example.com |
"Quoting the part before the @-sign is not allowed here." |
Good defaults. But three behaviours caught us out, and they are the reason this post exists.
Gotcha 1: it does not strip whitespace
validate_email(" jane@gmail.com ") raises an error about a SPACE character; call .strip() first.
The exact message was: "The email address contains invalid characters before the @-sign: SPACE." That is technically correct. It is also the most common way real users fail validation, because browsers' autofill and copy-paste routinely add a trailing space. Strip, then validate, then store the normalised value.
Gotcha 2: 65-character local parts pass unless you ask
By default the 64-octet local-part limit in RFC 5321 is not enforced; pass strict=True to enforce it.
We validated "a" * 65 + "@gmail.com" and it passed, MX records and all. The README documents why: strict=True performs "additional syntax checks (currently only a local part length check)" and is intended for "mail service providers at address creation". The overall address length is still checked: 250 characters before the @ was rejected as too long.
For a signup form, an address over 64 characters before the @ is almost certainly junk or an attack probe, so we pass strict=True. With it set, the same input raised "The email address is too long before the @-sign (1 character too many)."
Gotcha 3: DNS timeouts fail open, silently
When the DNS lookup times out, validate_email() returns normally and the result has no mx attribute at all.
We pointed a resolver at an unreachable name server with a 2-second timeout and called validate_email("jane@gmail.com"). It returned after about two seconds without raising. info.as_dict() contained everything except mx, and reading info.mx raised AttributeError: mx.
The source explains it. In deliverability.py a dns.exception.Timeout is caught with the comment "A timeout could occur for various reasons, so don't treat it as a failure", and in validate_email.py the mx attribute is only set when it is not None. The README says mx "may be None" in this case; in 2.3.0 it is absent rather than None.
Failing open is the right policy. You should not block a real customer because your resolver was slow. But you need to know it happened, so you can recheck later rather than marking the address as checked.
A complete check function
Strip, validate with a shared caching resolver, and turn the three DNS outcomes into explicit statuses.
from email_validator import validate_email, caching_resolver, EmailNotValidError
resolver = caching_resolver(timeout=3)
def check_email(raw: str) -> dict:
try:
info = validate_email(raw.strip(), dns_resolver=resolver, strict=True)
except EmailNotValidError as err:
return {"status": "invalid", "reason": str(err)}
mx = getattr(info, "mx", None)
if mx is None:
return {"status": "unknown", "email": info.normalized, "reason": "DNS did not answer in time"}
if info.mx_fallback_type:
return {"status": "review", "email": info.normalized, "reason": f"no MX, only {info.mx_fallback_type} record"}
return {"status": "deliverable-domain", "email": info.normalized, "mx": mx[0][1]}
Output from our run:
| Input | Status | Detail |
|---|---|---|
Jane.Doe@GMAIL.com |
deliverable-domain | normalised to Jane.Doe@gmail.com, MX gmail-smtp-in.l.google.com |
john..doe@example.com |
invalid | two periods in a row |
jane@example.com |
invalid | does not accept email |
jane@gmial.com |
review | no MX, only A record |
user@münchen.de |
review | no MX, only A record |
jane@outlook.com with DNS unreachable |
unknown | DNS did not answer in time |
Some notes on the choices.
normalized lower-cases the domain and leaves the local part alone. The README says to use "only the normalized form of the email address, especially before going to a database query". Do that, and deduplicate case-insensitively if you need to. Provider-specific rules such as Gmail ignoring dots are a separate decision, covered in plus addressing, Gmail dots and free-trial abuse.
review is not invalid. A domain with only an A record can legitimately receive mail under RFC 5321's implicit MX rule. münchen.de is real. gmial.com is a typo of gmail.com that happens to resolve. Both deserve a second look, not a hard rejection; the typo case is better handled with a "did you mean gmail.com?" prompt, which we cover in catching email typos at signup.
The caching resolver matters at volume. With caching_resolver, the first lookup for each of five large mail domains took 6 to 16 ms on our connection and the repeat took under half a millisecond. The default timeout without one is 15 seconds, according to the README, which is far too long to hold a signup request open.
On login forms, turn the DNS check off. The README recommends check_deliverability=False for login pages "since re-validation of a previously validated domain in your database by querying DNS at every login is probably undesirable".
When you need your own DNS logic: dnspython
If you want MX hosts for something other than validation, or different rules, use dns.resolver directly and handle NXDOMAIN, NoAnswer, null MX and timeouts separately.
email-validator already depends on dnspython, so there is nothing extra to install. Its resolver defaults to a 2-second per-server timeout and a 5-second total lifetime; per the dnspython docs, lifetime is "the number of seconds to spend trying to get an answer to the question", after which dns.exception.Timeout is raised.
import dns.exception
import dns.name
import dns.resolver
resolver = dns.resolver.Resolver()
resolver.lifetime = 3.0
def mail_hosts(domain: str) -> list[str] | None:
"""MX hosts in priority order; [] if the domain takes no mail; None if DNS failed."""
try:
return _lookup(domain)
except (dns.exception.Timeout, dns.resolver.NoNameservers):
return None
def _lookup(domain: str) -> list[str]:
try:
answer = resolver.resolve(domain, "MX")
except dns.resolver.NXDOMAIN:
return []
except dns.resolver.NoAnswer:
return [domain] if _has_address(domain) else []
records = sorted(answer, key=lambda r: r.preference)
if len(records) == 1 and records[0].exchange == dns.name.root:
return []
return [r.exchange.to_text(omit_final_dot=True) for r in records]
def _has_address(domain: str) -> bool:
for rdtype in ("A", "AAAA"):
try:
resolver.resolve(domain, rdtype)
return True
except dns.resolver.NoAnswer:
continue
return False
| Domain | mail_hosts() |
|---|---|
gmail.com |
five hosts, gmail-smtp-in.l.google.com first |
example.com |
[] (null MX, exchange is the DNS root) |
nonexistent-domain-sv-test-9a8b7c.com |
[] (NXDOMAIN) |
gmial.com |
['gmial.com'] (implicit MX) |
| any domain, with DNS unreachable | None |
The three-way return is the point. [] means "this domain does not take mail", which is a fact. None means "we do not know", which is not.
The mailbox check: smtplib, with care
smtplib can run the EHLO, MAIL FROM, RCPT TO conversation and quit before sending anything, but only from a host with outbound port 25 open.
The email-validator README argues against this: "There is nothing to be gained by trying to actually contact an SMTP server", because servers "are good at not giving away whether an address is deliverable or not". That is fair for catch-all domains, greylisting servers and providers that accept first and bounce later. It is not true of every server. Gmail gave a definite answer when we asked:
import smtplib
import socket
def probe_rcpt(mx_host: str, rcpt: str, helo_name: str, mail_from: str, timeout: float = 10) -> dict:
try:
with smtplib.SMTP(mx_host, 25, local_hostname=helo_name, timeout=timeout) as smtp:
smtp.ehlo()
code, _ = smtp.mail(mail_from)
if code != 250:
return {"status": "unknown", "reason": f"MAIL FROM got {code}"}
code, message = smtp.rcpt(rcpt)
except (socket.timeout, TimeoutError):
return {"status": "unknown", "reason": "timeout (is outbound port 25 blocked?)"}
except (OSError, smtplib.SMTPException) as err:
return {"status": "unknown", "reason": type(err).__name__}
text = message.decode(errors="replace")
if code == 250:
return {"status": "accepted", "reply": text}
if 550 <= code <= 553 and text.startswith("5.1."):
return {"status": "rejected", "reply": text}
return {"status": "unknown", "reason": f"RCPT got {code}", "reply": text}
For a made-up Gmail address the reply began "5.1.1 The email account that you tried to reach does not exist." Pointed at an unroutable host with a 3-second timeout, it returned unknown with the timeout reason. The with block sends QUIT on exit, so no message is ever transmitted.
Two rules in that code matter more than the code:
- Only a
5.1.xenhanced status means the mailbox does not exist. A550 5.7.1is a policy rejection of your IP or HELO, not of the address. acceptedis notvaliduntil you have ruled out a catch-all by probing a random address at the same domain.
Running this at any volume needs a host that permits port 25 (most cloud providers do not), a clean IP with reverse DNS, a real HELO name and per-domain rate limits. That operational cost is why most teams stop at DNS and use a service for the mailbox check.
The practical takeaway
- Do not use regex or
parseaddras a validator. - Use
email-validator: strip first, passstrict=Trueon signup, and storeinfo.normalized. - Share one
caching_resolverwith a short timeout, and readmxwithgetattr(info, "mx", None)so a DNS timeout becomesunknownrather than a crash or a silent pass. - Treat A-record-only domains as review, not valid or invalid.
- If you need the mailbox-level answer, count only
5.1.xas a rejection and anything unconfirmed as risky. The JavaScript versions of all of this are in validating email in JavaScript and Node.js, and you can see what a full check returns for one address with the free email verifier.
Common questions
What is the best way to validate an email address in Python?
Use the email-validator package. validate_email() checks syntax, normalises the address and, by default, looks up the domain's MX records. Strip whitespace before calling it, store the normalized value it returns, and pass a caching resolver if you validate more than a handful of addresses.
Can email.utils.parseaddr validate an email address?
No. parseaddr is a header parser, not a validator. In our test it returned 'not' as the address for the input 'not an email', and returned john..doe@example.com unchanged even though consecutive dots are not allowed.
Does email-validator check whether the mailbox exists?
No. It checks syntax and DNS only. Its README says there is nothing to be gained by contacting an SMTP server, because servers often accept and bounce later. That is true for catch-all domains and some providers, though Gmail does reject unknown mailboxes at RCPT TO, as our test shows.
What happens if the DNS lookup times out in email-validator?
In version 2.3.0 validate_email() returns successfully and the result simply has no mx attribute. It does not raise. If you read info.mx directly you get an AttributeError, so use getattr(info, 'mx', None) and treat None as unknown.
Verify unlimited addresses for $29.99/month
Real SMTP mailbox checks. No credits, no per-email fees.
Get Started