All posts

Validating Email Addresses in Python: Syntax, MX and SMTP

··9 min read

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.x enhanced status means the mailbox does not exist. A 550 5.7.1 is a policy rejection of your IP or HELO, not of the address.
  • accepted is not valid until 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 parseaddr as a validator.
  • Use email-validator: strip first, pass strict=True on signup, and store info.normalized.
  • Share one caching_resolver with a short timeout, and read mx with getattr(info, "mx", None) so a DNS timeout becomes unknown rather 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.x as 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