Validating an email address in Python means three separate checks, and the one most tutorials show — a regex — is the least useful of them. Real validation is layered: syntax, then a DNS lookup, then an SMTP mailbox probe. Python's standard library and one small dependency cover the first two; the third is a network problem worth delegating. This guide builds each layer with working code.
The short answer#
Use a permissive regex (or email.utils.parseaddr) for syntax, dnspython for
the MX lookup, and a verification API for the SMTP mailbox check — cheapest
first, short-circuiting as soon as one is decisive. Don't reach for smtplib to
probe mailboxes from your app: outbound port 25 is blocked on most hosts, and the
result depends on sending-IP reputation and
greylisting you don't want to reimplement.
Layer 1: syntax#
Keep it permissive — catch typos, don't reimplement RFC 5322 (which wouldn't help anyway):
import re
SYNTAX = re.compile(r'^[^\s@"]+(?:\.[^\s@"]+)*@[^\s@.]+(?:\.[^\s@.]+)+$')
def is_valid_syntax(email: str) -> bool:
return isinstance(email, str) and len(email) <= 320 and SYNTAX.match(email) is not None
is_valid_syntax("jane@example.com") # True
is_valid_syntax("not-an-email") # False
is_valid_syntax("a@@b.com") # False
The standard library's email.utils.parseaddr will parse an address, but it is
lenient by design — parseaddr("nonsense") returns ('', 'nonsense') without
raising — so it's a parser, not a validator. A short regex is clearer for a yes/no
syntax gate.
Layer 2: can the domain receive mail?#
A domain with no MX records can't accept mail for anyone. dnspython resolves
them in a couple of lines:
import dns.resolver # pip install dnspython
def has_mail_route(domain: str) -> bool:
try:
answers = dns.resolver.resolve(domain, "MX")
return len(answers) > 0
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
return False
has_mail_route("gmail.com") # True
has_mail_route("company-that-folded.com") # False
This one lookup eliminates dead domains and made-up TLDs for free. If you want to
honour domains that accept mail on an A record with no MX, fall back to resolving
A/AAAA when the MX set is empty — but an MX check covers the vast majority of
real addresses.
Layer 3: does the mailbox actually exist?#
Layers 1 and 2 can only rule an address out. Confirming a mailbox means the
SMTP delivery conversation — and while smtplib can open one, running it from
your application is a bad idea: port 25 is widely blocked, the answer depends on
the reputation of the IP you connect from, and receiving servers greylist and
rate-limit strangers. Delegate this layer.
Qualisend's POST /verify runs the full pipeline. The local
checks return immediately with the SMTP probe queued:
import os
import requests # pip install requests
BASE = "https://app.qualisend.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['QUALISEND_API_KEY']}"}
def verify(email: str) -> dict:
res = requests.post(f"{BASE}/verify", headers=HEADERS, json={"email": email}, timeout=10)
res.raise_for_status()
return res.json()
For live signup validation the immediate result is usually enough to act on.
When you need the SMTP-confirmed verdict, poll the job until the probe finishes:
import time
def verify_and_wait(email: str, tries: int = 10, delay: float = 1.5):
job_id = verify(email)["job_id"]
for _ in range(tries):
res = requests.get(f"{BASE}/jobs/{job_id}",
params={"include": "results"}, headers=HEADERS, timeout=10)
job = res.json()
if job["status"] == "completed":
return job["results"][0] # {"status": ..., "score": ..., "reason": ...}
time.sleep(delay)
return None # still processing — treat as unknown, retry later
Verifying a list in bulk#
Don't loop verify() over a CSV one address at a time — that's one HTTP round-trip
and one job per row. Post the whole list to /verify in a single request, get back
one job_id, and poll once for the entire batch. The results array comes back in
submission order:
def verify_bulk(emails: list[str], tries: int = 60, delay: float = 2.0):
res = requests.post(f"{BASE}/verify", headers=HEADERS,
json={"emails": emails}, timeout=30)
res.raise_for_status()
job_id = res.json()["job_id"]
for _ in range(tries):
job = requests.get(f"{BASE}/jobs/{job_id}",
params={"include": "results"}, headers=HEADERS,
timeout=30).json()
if job["status"] == "completed":
return dict(zip(emails, job["results"]))
time.sleep(delay) # back off; large lists take longer than a single probe
return None
A batch of thousands can take minutes because each SMTP probe is a separate network
conversation, so widen tries/delay for bigger lists rather than hammering the
poll endpoint. Run the two local layers first to drop obvious junk before you spend
credits — filter with is_valid_syntax and has_mail_route, then send only the
survivors.
Async and pydantic notes#
requests is blocking, so in an asyncio app wrap the call with
asyncio.to_thread(verify, email) or switch to httpx.AsyncClient to fire the
verify calls concurrently; dnspython ships an async resolver (dns.asyncresolver)
for the same reason. If you already use pydantic, its EmailStr type is a clean
drop-in for Layer 1 — it validates syntax when a model is constructed — but it stops
there: it never touches DNS or the mailbox, so keep the MX check and the verification
API downstream of it.
Putting the layers together#
Cheapest first, stop as soon as you have an answer:
def validate_email(email: str) -> dict:
if not is_valid_syntax(email):
return {"status": "undeliverable", "reason": "invalid_email"}
domain = email.rsplit("@", 1)[1]
if not has_mail_route(domain):
return {"status": "undeliverable", "reason": "invalid_domain"}
return verify(email)["result"] # deliverable | risky | undeliverable | unknown
The two local layers cost nothing and catch most junk instantly; the API runs only on addresses worth the round-trip. See how email verification works for why the stages sit in this order.
Frequently asked questions#
Can I verify an email in Python with just the standard library?#
Up to a point. re handles syntax and, with socket, you can attempt DNS — but
there's no built-in MX resolver (hence dnspython), and confirming a mailbox
needs an SMTP probe you shouldn't run from your app. The standard library gets you
syntax and a rough domain check; the mailbox layer needs dnspython plus a
verification service.
Why not use smtplib to check if a mailbox exists?#
smtplib can open the SMTP conversation, but running it from your application
server is unreliable and risky: most hosts block outbound port 25, the response
depends on your sending IP's reputation, and probing at any volume gets you
rate-limited or blocklisted. A verification API runs the probe from
reputation-managed infrastructure built for it.
Is dnspython the right library for MX lookups?#
Yes — it's the standard, well-maintained DNS toolkit for Python and resolves MX
records cleanly. Install it with pip install dnspython and catch NXDOMAIN,
NoAnswer, and NoNameservers to treat a missing mail route as undeliverable.
Does pydantic's EmailStr fully validate an email address?#
No — EmailStr (backed by the email-validator package) only checks that a string
is syntactically a valid address when your model is constructed. It doesn't confirm
the domain has MX records or that the mailbox exists, so it maps exactly to Layer 1.
Use it as the syntax gate in your pydantic models, then run the DNS lookup and the
verification API on the addresses that pass. It also normalises the address (lowercases
the domain), which is handy before you dedupe a list.
Ready to add the SMTP layer? The free plan includes 100 credits that
run the complete pipeline, and the API reference has the full
/verify and /jobs endpoints with copy-paste examples in seven languages.