What we'll build
A regular expression can tell you an address looks right. It can't tell you whether the domain exists, whether it accepts mail, or whether it's a throwaway inbox. In this guide we'll build a validate_email() function in Python that checks all of those and returns one of four statuses:
| Status | Meaning | Typical action |
|---|---|---|
valid | Well formed, and the domain accepts mail | Accept |
invalid | Bad syntax, no such domain, or no mail server | Reject and ask the user to fix it |
risky | Disposable, a likely typo, a role account, or no MX records | Your call: block, warn, or flag |
unknown | DNS failed temporarily | Accept and check again later |
You'll need Python 3.10 or later and two packages: dnspython for DNS lookups and a list of disposable domains.
pip install dnspython disposable-email-domainsWant to see the end result first? The free email checker runs the same checks.
1. Check the syntax
Start with the check that needs no network. Instead of one huge regular expression, split the address at the last @ and check each part separately. The rules stay readable, and you can say exactly what's wrong:
import re
# RFC 5322 "atext": the characters allowed in each dot-separated part of the local part.
ATOM = re.compile(r"[a-z0-9!#$%&'*+/=?^_`{|}~-]+", re.IGNORECASE)
# A DNS label: letters, digits and hyphens, not starting or ending with a hyphen.
LABEL = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
def to_ascii_domain(domain):
"""Lower-cases a domain and converts international names to punycode, or returns None."""
try:
return domain.strip().rstrip(".").encode("idna").decode("ascii").lower()
except UnicodeError:
return None
def check_syntax(value):
email = str(value).strip()
if len(email) > 254:
return {"ok": False, "reason": "too_long"}
local, at, raw_domain = email.rpartition("@")
if not at or not local or not raw_domain:
return {"ok": False, "reason": "missing_at_or_part"}
if len(local.encode()) > 64:
return {"ok": False, "reason": "local_part_too_long"}
# Splitting on "." also rejects leading, trailing and doubled dots (they create empty atoms).
if not all(ATOM.fullmatch(atom) for atom in local.split(".")):
return {"ok": False, "reason": "invalid_local_part"}
domain = to_ascii_domain(raw_domain)
labels = domain.split(".") if domain else []
if len(labels) < 2 or not all(LABEL.fullmatch(label) for label in labels) or labels[-1].isdigit():
return {"ok": False, "reason": "invalid_domain"}
return {"ok": True, "local": local, "domain": domain, "normalized": f"{local.lower()}@{domain}"}A few details matter here:
- Python's built-in
idnacodec converts international domains to punycode (münchen.debecomesxn--mnchen-3ya.de), which is the form DNS needs. It raisesUnicodeErrorfor empty or over-long labels, which we treat as invalid. - The patterns use
fullmatch(). Withre.match()and a$anchor,"jane\n"would pass, because$also matches just before a trailing newline. - Splitting the local part on dots means
.jane,jane.andja..neall produce an empty part that fails, with no extra rules. - The length limits come from RFC 5321: 64 bytes for the local part and 254 characters overall.
Only lower-case the local part when comparing addresses, to stop duplicate sign-ups. Store what the user typed, because a few mail servers still treat Jane and jane as different mailboxes.
2. Check the domain's MX records
A well-formed address is no use if its domain doesn't exist or doesn't accept mail. The domain's MX (mail exchanger) records name the servers that receive its mail. dnspython looks them up and raises a different exception for each kind of failure:
import dns.exception
import dns.name
import dns.resolver
# A dedicated resolver with a short time limit, so a slow DNS server can't stall a sign-up.
resolver = dns.resolver.Resolver()
resolver.lifetime = 3.0
def has_address(domain):
for record_type in ("A", "AAAA"):
try:
resolver.resolve(domain, record_type)
return True
except dns.exception.DNSException:
continue
return False
def check_domain(domain):
try:
answer = resolver.resolve(domain, "MX")
records = sorted(answer, key=lambda r: r.preference)
# RFC 7505 "null MX": a single record pointing at "." means "this domain accepts no mail".
if len(records) == 1 and records[0].exchange == dns.name.root:
return {"status": "invalid", "reason": "domain_rejects_mail"}
return {"status": "ok", "mx": [r.exchange.to_text(omit_final_dot=True) for r in records]}
except dns.resolver.NXDOMAIN:
return {"status": "invalid", "reason": "domain_not_found"}
except dns.resolver.NoAnswer:
pass # The domain exists but has no MX records. Handled below.
except dns.exception.DNSException:
# SERVFAIL, timeouts and other DNS trouble are temporary. Don't blame the user.
return {"status": "unknown", "reason": "dns_error"}
# No MX records: mail servers fall back to the domain's own A/AAAA record ("implicit MX").
if has_address(domain):
return {"status": "risky", "reason": "no_mx_records"}
return {"status": "invalid", "reason": "no_mail_server"}| Result | Meaning | Status |
|---|---|---|
| MX records returned | The domain accepts mail | ok |
One MX record pointing at . | "Null MX" (RFC 7505): the domain accepts no mail | invalid |
NXDOMAIN | The domain doesn't exist | invalid |
NoAnswer | The domain exists but has no MX records, so check A/AAAA | risky or invalid |
NoNameservers, LifetimeTimeout | SERVFAIL or a timeout: DNS trouble, not the user's fault | unknown |
Two things are easy to get wrong. Set a short lifetime on the resolver, because the default lets a slow DNS server hold up the request for several seconds. And never treat a timeout as "invalid", or you'll turn away real users whenever DNS has a bad moment.
3. Cache DNS lookups
Most of your users share a handful of domains like gmail.com and outlook.com, so there's no need to query DNS for every sign-up:
import time
from dns_check import check_domain
TTL_SECONDS = 3600
_cache = {}
def check_domain_cached(domain):
hit = _cache.get(domain)
if hit and hit[0] > time.monotonic():
return hit[1]
result = check_domain(domain)
# Don't remember temporary failures.
if result["status"] != "unknown":
_cache[domain] = (time.monotonic() + TTL_SECONDS, result)
return resultFailed lookups aren't cached, so the next sign-up on that domain tries again. This plain dict grows with every new domain. For a long-running server, use a bounded cache such as cachetools.TTLCache, and if you run several worker processes, each one keeps its own cache.
4. Detect disposable domains
Temporary inboxes from services like Mailinator pass every check so far: the syntax is fine and the domains have working mail servers. The only way to catch them is a list. The disposable-email-domains package ships the community-maintained list as a Python set:
from disposable_email_domains import blocklist # a set of domain names
def is_disposable(domain):
"""Checks the domain and each parent, so "x.mailinator.com" matches "mailinator.com"."""
labels = domain.split(".")
return any(".".join(labels[i:]) in blocklist for i in range(len(labels) - 1))Checking parent domains matters because many disposable services hand out addresses on random subdomains. Update the package regularly, since new disposable domains appear every week.
The list also contains some typo domains, such as gmial.com and hotmial.com. Someone registered them to collect mail meant for Gmail and Hotmail users, which is why step 6 still returns a did_you_mean suggestion for a disposable address. Read more about how disposable email detection works.
5. Flag role accounts and suggest typo fixes
Two more signals are worth having. Role accounts like info@ and support@ reach a shared inbox rather than a person. And typos like gamil.com are one of the commonest reasons a sign-up email never arrives:
ROLE_ACCOUNTS = {
"admin", "billing", "contact", "help", "hello", "info", "marketing", "no-reply",
"noreply", "office", "postmaster", "sales", "support", "team", "webmaster",
}
def is_role_account(local):
# Ignore "+tag" sub-addressing: "support+billing" is still "support".
return local.lower().split("+")[0] in ROLE_ACCOUNTS
# Real providers people mistype. Include close neighbours (mail.com, ymail.com, gmx.com…)
# too, or they'd be "corrected" to the provider one letter away.
POPULAR_DOMAINS = [
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "yahoo.co.uk", "hotmail.com",
"hotmail.co.uk", "outlook.com", "live.com", "msn.com", "icloud.com", "me.com", "mac.com",
"aol.com", "mail.com", "gmx.com", "gmx.de", "proton.me", "protonmail.com",
]
def distance(a, b):
"""Levenshtein distance where swapping two adjacent letters counts as one edit."""
d = [[i + j if i == 0 or j == 0 else 0 for j in range(len(b) + 1)] for i in range(len(a) + 1)]
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 1):
cost = 0 if a[i - 1] == b[j - 1] else 1
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1)
return d[len(a)][len(b)]
def suggest_domain(domain):
""""gmial.com" -> "gmail.com"; None when the domain is fine or nothing is close."""
if domain in POPULAR_DOMAINS:
return None
best, best_distance = min(((c, distance(domain, c)) for c in POPULAR_DOMAINS), key=lambda x: x[1])
return best if best_distance <= (1 if len(domain) <= 6 else 2) else NonePython has no built-in edit distance, so distance() is a standard Levenshtein implementation with one extra rule: swapping two neighbouring letters (gmial → gmail) counts as a single edit. Short domains only get one edit's leeway, because a two-letter difference on me.com is a different domain.
The list of domains matters as much as the algorithm. Real providers that sit one letter apart, like mail.com and gmail.com, must both be in it, or users of one get "corrected" to the other. Even then, some genuine domains will look like typos (mx.com is one edit from me.com), so show the result as a suggestion and let the user decide.
6. Put it together
Now run the checks from cheapest to most expensive and return a single verdict:
from disposable import is_disposable
from dns_cache import check_domain_cached
from extras import is_role_account, suggest_domain
from syntax import check_syntax
def validate_email(value):
"""Returns {"status", "reason", "email", "did_you_mean"}.
status is "valid", "invalid", "risky" or "unknown".
"""
syntax = check_syntax(value)
if not syntax["ok"]:
return {"status": "invalid", "reason": syntax["reason"], "email": None, "did_you_mean": None}
local, domain = syntax["local"], syntax["domain"]
suggestion = suggest_domain(domain)
base = {"email": syntax["normalized"], "did_you_mean": f"{local}@{suggestion}" if suggestion else None}
dns_result = check_domain_cached(domain)
if dns_result["status"] in ("invalid", "unknown"):
return {**base, "status": dns_result["status"], "reason": dns_result["reason"]}
# The domain can receive mail. Now look for risk signals, most serious first.
if is_disposable(domain):
return {**base, "status": "risky", "reason": "disposable"}
if suggestion:
return {**base, "status": "risky", "reason": "possible_typo"}
if dns_result["status"] == "risky":
return {**base, "status": "risky", "reason": dns_result["reason"]}
if is_role_account(local):
return {**base, "status": "risky", "reason": "role_based"}
return {**base, "status": "valid", "reason": "mail_server_found"}Here's what it returns for a range of inputs:
| Input | status | reason | did_you_mean |
|---|---|---|---|
jane@gmail.com | valid | mail_server_found | None |
joe@gamil.com | risky | possible_typo | joe@gmail.com |
test@mailinator.com | risky | disposable | None |
support@acme.io | risky | role_based | None |
x@no-such-domain.example | invalid | domain_not_found | None |
not-an-email | invalid | missing_at_or_part | None |
7. Use it in Flask or Django
A typical sign-up policy is to reject invalid and disposable addresses, accept everything else, and pass the typo suggestion back so the front end can ask "Did you mean …?". In Flask:
from flask import Flask, jsonify, request
from validation import validate_email
app = Flask(__name__)
@app.post("/signup")
def signup():
payload = request.get_json(silent=True) or {}
result = validate_email(payload.get("email", ""))
if result["status"] == "invalid" or result["reason"] == "disposable":
if result["reason"] == "disposable":
message = "Please use a permanent email address."
else:
message = "That email address can't receive mail."
return jsonify(error=message, did_you_mean=result["did_you_mean"]), 422
# "unknown" means DNS had a hiccup. Accept the address and check it again later.
# create_user(result["email"])
return jsonify(ok=True, email=result["email"], did_you_mean=result["did_you_mean"]), 201In Django, put the same policy in the form's clean_email() method. Django's EmailField checks the format first, so validate_email() only runs on addresses that look right:
from django import forms
from validation import validate_email
class SignupForm(forms.Form):
email = forms.EmailField()
def clean_email(self):
result = validate_email(self.cleaned_data["email"])
if result["status"] == "invalid":
raise forms.ValidationError("That email address can't receive mail.")
if result["reason"] == "disposable":
raise forms.ValidationError("Please use a permanent email address.")
return result["email"]Adjust the policy to your product. A B2B form might also reject free providers, while a newsletter might accept role accounts. Always validate on the server, because client-side checks can be bypassed.
8. Test it
The syntax check has no side effects, so it's easy to test with pytest:
import pytest
from syntax import check_syntax
@pytest.mark.parametrize("email", ["jane@example.com", "jane.doe+news@example.co.uk", "user@münchen.de"])
def test_accepts_normal_addresses(email):
assert check_syntax(email)["ok"]
@pytest.mark.parametrize(
"email", ["", "jane", "@example.com", "jane@", "a..b@example.com", "jane@example", "jane@-example.com"]
)
def test_rejects_malformed_addresses(email):
assert not check_syntax(email)["ok"]For the DNS checks, the resolver is a module-level object, so tests can point it at a local test DNS server with resolver.nameservers = ["127.0.0.1"] and resolver.port = 5353. Then your test suite doesn't depend on the internet. That's how we tested every result on this page.
What about checking that the mailbox exists?
You could go further with smtplib: connect to the domain's mail server on port 25 and ask whether a specific mailbox exists with RCPT TO. We don't recommend doing that inside a request handler. Many hosting providers block outbound port 25, large providers often accept every recipient at that stage, and catch-all domains and greylisting make the answer inconclusive.
The email validation guide covers SMTP verification in more depth. For most sign-up forms, the checks above plus a confirmation email are the best balance of accuracy and speed.
Or use an email validation API
Everything above works, but you have to maintain it: the disposable list goes stale, the typo list needs tuning, and DNS edge cases keep turning up. The validateemail.io API runs all of these checks in one request:
import logging
import os
import requests
API = "https://validateemail.io/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['VALIDATEEMAIL_KEY']}"
def validate_with_api(email):
try:
res = session.get(f"{API}/validate", params={"email": email}, timeout=5)
res.raise_for_status()
return res.json()
except requests.RequestException as err:
# Never block a sign-up because the validator is slow or unreachable.
logging.warning("Email validation unavailable: %s", err)
return {"status": "unknown", "reason": "validator_unavailable"}
def validate_list(emails):
"""Validates any number of addresses, 100 per request."""
results = []
for i in range(0, len(emails), 100):
res = session.post(f"{API}/validate/batch", json={"emails": emails[i : i + 100]}, timeout=30)
res.raise_for_status()
results.extend(res.json()["results"])
return resultsIf the API is slow or unreachable, validate_with_api() returns unknown instead of raising, so a network problem never blocks a sign-up. The response has the same status, reason and did_you_mean fields used in this guide, plus a score and the result of every individual check.
Frequently asked questions
What is the best regex to validate an email in Python?
For a quick format check, re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email) is enough. Use fullmatch() rather than match() with $, because $ also matches before a trailing newline. No regex can tell you whether the domain accepts mail, so on the server add an MX lookup as shown in this guide.
How do I check if an email domain exists in Python?
Install dnspython and call dns.resolver.resolve(domain, "MX"). It raises NXDOMAIN when the domain doesn't exist and NoAnswer when it exists but has no MX records. A single MX record pointing at . is a "null MX" that accepts no mail. Treat timeouts and SERVFAIL as temporary rather than invalid.
Is the email-validator package enough?
The email-validator package does a thorough syntax check and can check that the domain accepts mail. It doesn't detect disposable inboxes, role accounts or typos like gmial.com, so you'd still add steps 4 and 5 from this guide.
Can Python check that a mailbox exists without sending an email?
You can try with smtplib: connect to the domain's mail server and send RCPT TO. In practice port 25 is often blocked, large providers accept every recipient at that stage, and catch-all domains accept anything, so the answer is often unreliable. A confirmation email is still the only definitive test.