How to validate email addresses in Python
"Is this email valid?" can mean four different things, and each one catches a different kind of bad address:
- Syntax: is it shaped like an address? Catches
jane@@exampleand pasted junk. - Domain: does the domain exist and accept mail (MX records)? Catches
jane@gmial.con. - Disposable: is it a throwaway inbox from a temporary-mail service? Catches trial abuse.
- Mailbox: does that particular mailbox exist? Catches
jnae@gmail.com.
The first three you can do yourself in a few lines of Python. The fourth is where it gets hard, and we'll get to why.
1. Check the syntax
Don't write a regex. The real grammar (RFC 5322 plus internationalized addresses) is far looser and stranger than any regex you'd want to maintain. Use the email-validator package, which also normalizes the address:
pip install email-validator dnspythonfrom email_validator import validate_email, EmailNotValidError
def check_syntax(address: str) -> str | None:
"""Return the normalized address, or None if it isn't a valid address."""
try:
return validate_email(address, check_deliverability=False).normalized
except EmailNotValidError:
return None
print(check_syntax("Jane.Doe@Example.COM")) # Jane.Doe@example.com
print(check_syntax("jane@@example")) # NoneNote the domain is lowercased but the local part (before the @) is not: strictly, Jane and jane can be different mailboxes, although almost every provider treats them the same.
2. Check that the domain accepts mail
A domain receives mail if it has MX records. If it has none, mail servers fall back to its A or AAAA record (the "implicit MX" rule in RFC 5321). One special case: an MX of 0 . (a "null MX") means the domain explicitly accepts no mail.
import dns.resolver
import dns.exception
def mail_hosts(domain: str) -> list[str] | None:
"""MX hosts for a domain, [] if it accepts no mail, None if DNS didn't answer."""
resolver = dns.resolver.Resolver()
resolver.lifetime = 5
try:
answers = resolver.resolve(domain, "MX")
hosts = [str(r.exchange).rstrip(".") for r in sorted(answers, key=lambda r: r.preference)]
return [h for h in hosts if h] # a null MX ("0 .") leaves nothing
except dns.resolver.NXDOMAIN:
return [] # the domain doesn't exist
except dns.resolver.NoAnswer:
try: # no MX: fall back to the A record
resolver.resolve(domain, "A")
return [domain]
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return []
except dns.exception.DNSException:
return None # timeout or server failure: don't reject on this
print(mail_hosts("gmail.com")) # ['gmail-smtp-in.l.google.com', ...]
print(mail_hosts("gmial.con")) # []3. Catch disposable addresses
Temporary-mail services hand out inboxes that vanish after minutes. The community-maintained disposable-email-domains list has close to 10,000 of their domains and is free (CC0):
import urllib.request
LIST_URL = ("https://raw.githubusercontent.com/disposable-email-domains/"
"disposable-email-domains/main/disposable_email_blocklist.conf")
def load_disposable() -> set[str]:
with urllib.request.urlopen(LIST_URL, timeout=10) as f:
return {line.strip().lower() for line in f.read().decode().splitlines()
if line.strip() and not line.startswith("#")}
DISPOSABLE = load_disposable() # load once at startup, refresh weekly
def is_disposable(domain: str) -> bool:
parts = domain.lower().split(".")
# also match subdomains: x.mailinator.com is mailinator.com
return any(".".join(parts[i:]) in DISPOSABLE for i in range(len(parts) - 1))A list is always behind: new throwaway domains appear every day. See how to detect disposable emails for the gaps and what else to look at.
4. The hard part: does the mailbox exist?
The only way to know is to ask the recipient's mail server: connect on port 25, say HELO, MAIL FROM, RCPT TO:<jane@example.com>, read the reply, and disconnect without sending anything. It looks easy with smtplib. In practice it rarely works from your own app:
- Port 25 is blocked on most cloud servers (AWS, Google Cloud and Azure block outbound 25 by default) and on home connections.
- Your IP's reputation suffers. Many
RCPTchecks without sending looks like address harvesting. Microsoft, Yahoo and blocklists like Spamhaus notice, and if the probes come from the same IP as your app's own email, your signup and password-reset mail starts bouncing. - Answers are often meaningless. Yahoo and AOL accept every address at this stage. Catch-all domains say yes to anything. Greylisting servers say "try later". Security gateways (Proofpoint, Mimecast) refuse to answer.
Doing this properly takes a separate IP with its own reverse DNS, per-domain rate limits, a list of providers that never give real answers, catch-all detection and an automatic stop when servers start refusing. That's the part worth handing to a service.
5. All four checks in one request
With the Verified.net API, one call does all of the above, plus typo suggestions (gmial.com → gmail.com), role-address detection (info@, sales@) and a verdict your code can branch on:
import os
import requests
API_KEY = os.environ["VERIFIED_KEY"]
def check_email(address: str) -> dict | None:
"""Verified.net email check, or None if the service couldn't be reached."""
try:
r = requests.post(
"https://api.verified.net/v1/email",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"address": address},
timeout=10,
)
except requests.RequestException:
return None
if r.status_code != 200:
return None # 402 = out of credits, 429 = rate limited; fail open
return r.json()
result = check_email("jane.doe@gmial.com")
if result:
print(result["recommendation"]) # {'action': 'review', 'reasons': ['possible_typo']}
print(result["did_you_mean"]) # jane.doe@gmail.comThe recommendation.action is accept, review or reject, and reasons says why (disposable, no_mail_server, mailbox_not_found, catch_all, role_account, possible_typo, ...). The full field list is in the API docs. Each check costs 1 credit; invalid syntax is free, and checking the same address again within 7 days is free.
6. Use it in a signup form
Here it is in a Django form. The same idea works in Flask, FastAPI or anywhere else: reject the clear failures, offer the typo fix, and let everything uncertain through.
from django import forms
class SignupForm(forms.Form):
email = forms.EmailField()
def clean_email(self):
email = self.cleaned_data["email"]
result = check_email(email)
if result is None:
return email # service unreachable: don't block signups
if result.get("did_you_mean"):
raise forms.ValidationError(f"Did you mean {result['did_you_mean']}?")
if result["recommendation"]["action"] == "reject":
raise forms.ValidationError("Please use a working, permanent email address.")
return emailPractical tips
- Fail open. If validation times out, accept the address and check it later. A broken signup form costs more than one bad address.
- "Review" isn't "reject". Catch-all and role addresses are often real businesses. Accept them, but maybe require email confirmation before granting free credits.
- Check lists before you send, not just at signup. Addresses go bad over time. Re-check a list in bulk before a campaign (the API's bulk jobs take a CSV of up to 100,000 rows).
- Still send a confirmation email for anything that matters. Validation tells you the address can receive mail, not that it belongs to the person typing it.
Try it on your own data
Check an address or number now with the free checker, no signup needed, or start a 7-day trial and call the API from your code.
Start free trial API docs