Python email validation usually means one of three checks: is the address well-formed, does its domain receive mail, and does the mailbox exist. The standard library has no ready-made validator for the first, and no format check can answer the other two. This guide builds all three, in the order they run, with code we ran on Python 3.14 against a local DNS server and a local mock of the API.
1. Validate the format with a regex
Without extra packages, use the tested pattern from our email regex guide with re.fullmatch:
# email_regex.py
import re
EMAIL_REGEX = re.compile(
r"(?=.{1,254}\Z)(?=.{1,64}@)"
r"[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
r"@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+"
r"(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})"
)
def is_valid_email(value: str) -> bool:
return EMAIL_REGEX.fullmatch(value) is not None
The lookaheads enforce the length limits (254 characters in total, 64 before the @). The rest allows plus tags and apostrophes, and rejects dots at the edges of the local part, double dots and domains without a top-level domain; the email address format guide explains each rule. Use fullmatch(): with re.match() and a pattern ending in $, Python accepts "jane@example.com\n", because $ also matches before a final line break.
The regex knows internationalized domains only in punycode (xn--…), and it doesn’t trim. The library in the next step does both.
2. Validate and normalize with email-validator
The email-validator package checks the syntax rule by rule, gives a readable reason when an address fails, and returns a normalized form:
# normalize.py: pip install email-validator
from email_validator import EmailNotValidError, validate_email
def normalize_email(value: str) -> str:
"""Returns the address in ASCII form, or raises EmailNotValidError with a readable message."""
info = validate_email(
value.strip(),
check_deliverability=False, # no DNS lookups here
allow_smtputf8=False, # reject non-ASCII local parts such as josé@
strict=True, # enforce the 64-character limit before the @
)
return info.ascii_email # internationalized domains in punycode
The three keyword arguments matter. allow_smtputf8=False rejects addresses such as josé@example.com, which only reach mail servers that support SMTPUTF8. strict=True turns on the 64-character limit for the local part, which version 2.3.0 doesn’t enforce by default. And ascii_email returns the domain in punycode, while normalized keeps it in Unicode.
Every address below went through the regex, normalize_email and Pydantic’s EmailStr, which runs email-validator with its defaults. ✓ means the check accepts the address, ✗ that it rejects it.
| Address | Should pass? | Regex | normalize_email | EmailStr |
|---|---|---|---|---|
jane@example.com | Yes | ✓ | ✓ | ✓ |
jane.doe+news@ | Yes | ✓ | ✓ | ✓ |
o'brien@ | Yes | ✓ | ✓ | ✓ |
Anna@Example.COM | Yes | ✓ | ✓ Anna@example.com | ✓ Anna@example.com |
jane@ | Yes | ✓ | ✓ | ✓ jane@ |
jane@ | After IDN conversion | ✗ | ✓ jane@ | ✓ |
jane | No | ✗ | ✗ | ✗ |
jane..doe@ | No | ✗ | ✗ | ✗ |
jane@example | No | ✗ | ✗ | ✗ |
jane@example.c | No | ✗ | ✓ | ✓ |
jane@-example.com | No | ✗ | ✗ | ✗ |
jane@ | No | ✗ | ✗ | ✗ |
jane doe@example.com | No | ✗ | ✗ | ✗ |
"jane doe"@example.com | No (RFC-valid) | ✗ | ✗ | ✗ |
jane@[192 | No (RFC-valid) | ✗ | ✗ | ✗ |
josé@example.com | No (needs SMTPUTF8) | ✗ | ✗ | ✓ |
aaa…a@ (65 a’s) | No | ✗ | ✗ | ✓ |
jane@example.com with spaces around it | After trimming | ✗ | ✓ | ✓ |
jane@mail.local | No (reserved name) | ✓ | ✗ | ✗ |
Tested with Python 3.14, email-validator 2.3.0 and Pydantic 2.14. The error messages are written for people: for jane..doe@example.com the exception says “An email address cannot have two periods in a row.” email-validator also refuses special-use domains such as .local, .test and localhost, which the regex lets through. Note what EmailStr does to the domain: it returns jane@xn--bcher-kva.example as jane@bücher.example, so convert it back before you send it to an API or a mail server.
3. Check the domain’s MX records
A well-formed address on a domain without mail servers still bounces. email-validator can check that too: validate_email(email, check_deliverability=True) looks up the domain’s MX records. Against our local test DNS server, it rejected a null MX (“does not accept email”) and a domain that doesn’t exist, fell back to the A or AAAA record of a domain without MX records, and accepted the address when the DNS server failed or timed out. It also checks that a fallback address is public: an A record in a documentation range such as 192.0.2.0/24 was rejected.
If you want the answer as a value instead of an exception, with “unknown” kept apart from “no”, dnspython gives you the pieces:
# mx.py: pip install dnspython
import dns.exception
import dns.resolver
resolver = dns.resolver.Resolver()
resolver.lifetime = 3.0 # seconds for each lookup, retries included
def accepts_mail(domain: str) -> bool | None:
"""True: the domain accepts mail. False: it doesn't. None: DNS gave no clear answer."""
try:
answer = resolver.resolve(domain, "MX")
hosts = [r.exchange.to_text() for r in answer]
return hosts != ["."] # a single "." is a null MX (RFC 7505): no mail
except dns.resolver.NXDOMAIN:
return False # the domain doesn't exist
except dns.resolver.NoAnswer:
pass # no MX record: try the A/AAAA fallback below
except dns.exception.DNSException:
return None # timeout, SERVFAIL: unknown, don't reject
# No MX record: senders deliver to the domain's A or AAAA record (implicit MX).
unknown = False
for rdtype in ("A", "AAAA"):
try:
resolver.resolve(domain, rdtype)
return True
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
pass
except dns.exception.DNSException:
unknown = True
return None if unknown else False
With dnspython 2.9.1 against the test server:
| Domain set up with | accepts_mail |
|---|---|
| Two MX records | True |
Null MX (0 .) | False |
| A record only | True |
| AAAA record only | True |
| No such domain (NXDOMAIN) | False |
| SERVFAIL | None |
| No answer | None, after about 3 seconds |
NoNameservers, which dnspython raises when every server fails, and LifetimeTimeout are both subclasses of DNSException, so they end up as None. Treat None as “unknown” and don’t reject the address for it. A single MX record with the host . is how a domain says it accepts no email (RFC 7505); example.com publishes one. The MX record guide explains the records, and the MX lookup tool checks a domain by hand.
4. Check that the mailbox exists
A domain with working MX records accepts mail, but that doesn’t mean anna@example.org exists. Only the receiving mail server knows, and asking it with smtplib from your own server is unreliable: outbound port 25 is blocked at most cloud providers, catch-all domains accept every address, and probing can get your IP blocked. The email validation API runs the syntax, DNS, MX and mailbox checks in one request without sending an email:
# check_mailbox.py: pip install requests
import os
import requests
API_URL = "https://api.emailvalidation.io/v1/info"
def check_mailbox(email: str) -> dict:
"""Returns the API's result. Raises requests.RequestException on errors and timeouts."""
response = requests.get(
API_URL,
params={"email": email, "timeout": 8}, # the API answers within 8 seconds ...
headers={"apikey": os.environ["EMAILVALIDATION_KEY"]},
timeout=10, # ... and the client waits a little longer
)
response.raise_for_status() # 401 wrong key, 429 quota or concurrency limit
return response.json()
The key comes from the environment and travels in the apikey header, so it stays out of your code and out of URLs. requests encodes the + in jane+news@example.org as %2B, so the tag arrives intact. The API’s timeout parameter (3 to 60 seconds, default 30) caps how long it waits for a slow mail server; the documentation recommends a client timeout a few seconds longer. Always pass timeout to requests: without it, a request can wait forever. In our test a wrong key raised HTTPError with 401, a used-up quota HTTPError with 429, and an answer slower than 10 seconds ReadTimeout; all three are subclasses of requests.RequestException.
The response has 21 fields. The ones you’ll branch on:
status | Example sub_status | recommended_action | What to do |
|---|---|---|---|
valid | null, role_based, possible_typo | send, send_ | Accept; show did_you_mean if it isn’t empty. |
invalid | mailbox_, no_dns_entries | suppress | Reject. |
catch_all | accept_all | send_ | Accept or flag: the domain takes every address. |
risky | mailbox_full | retry_later | Accept; the mailbox is full right now. |
do_not_mail | disposable | suppress | Ask for a permanent address. |
unknown | timeout_exceeded, greylisted | retry_later | Accept and recheck later. |
The older fields state (deliverable, undeliverable, unknown) and reason carry the same verdict in less detail; the result statuses page maps them. Why catch-all domains can’t be verified per mailbox is explained in catch-all email.
There is also an official SDK, pip install emailvalidationio: emailvalidationio sends the same request. In version 0.2 it sets no request timeout, so for a sign-up form the few lines of requests above are the safer choice.
5. Validate email in a FastAPI endpoint
In a web app, the format check answers at once and the API check runs with its timeout. If the API is slow, down or out of quota, the sign-up still goes through:
# app.py: pip install fastapi uvicorn email-validator requests
import logging
import requests
from email_validator import EmailNotValidError
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from check_mailbox import check_mailbox
from normalize import normalize_email
app = FastAPI()
log = logging.getLogger("signup")
class Signup(BaseModel):
email: str
@app.post("/signup", status_code=201)
def signup(body: Signup): # a plain def: FastAPI runs it in a thread pool, so blocking calls are fine
try:
email = normalize_email(body.email)
except EmailNotValidError as err:
raise HTTPException(422, f"Please enter a valid email address. {err}")
result = None
try:
result = check_mailbox(email)
except requests.RequestException as err:
log.warning("email check skipped: %s", err) # API down or slow: don't block the sign-up
if result and result["status"] == "invalid":
raise HTTPException(422, "This email address doesn't exist. Please check it.")
if result and result["status"] == "do_not_mail":
raise HTTPException(422, "Please use a permanent email address.")
# create the account ...
return {"email": email, "did_you_mean": (result or {}).get("did_you_mean") or None}
email is a plain str on purpose: with EmailStr, Pydantic would reject bad addresses with its own error format and accept josé@ and 65-character local parts (see the table above). We ran the endpoint with FastAPI 0.162 and its TestClient against a local mock of the API that returns the documented response format:
Posted email | Response |
|---|---|
anna@example.org | 201 |
anna@example.org with spaces around it | 201, saved as anna@example.org |
jane@ | 201, sent to the API as jane@ |
jane@example | 422, “Please enter a valid email address. The part after the @-sign is not valid. It should have a period.” |
josé@example.com | 422, with email-validator’s reason |
An address the API reports as invalid | 422, “This email address doesn’t exist. Please check it.” |
An address the API reports as do_not_mail | 422, “Please use a permanent email address.” |
| An address on a catch-all domain | 201 |
anna@gmial.com | 201 with "did_you_mean": "anna@gmail.com" |
| Any address while the API answers 429 | 201, warning logged |
In Django, the same logic fits a form’s clean_email method; in Flask, the view function.
6. Validate a CSV file with the bulk API
Calling /v1/info once per row works for small lists, but your plan limits how many checks run at once (5 on the free plan, 25 on paid plans), and the free plan allows 10 requests a minute. For a whole file, send it to the bulk endpoint: one request uploads up to 100,000 rows, and the API returns your columns plus 18 result columns.
# bulk.py: python bulk.py contacts.csv
import os
import sys
import time
import requests
API = "https://api.emailvalidation.io/v1/bulk"
HEADERS = {"apikey": os.environ["EMAILVALIDATION_KEY"]}
path = sys.argv[1]
# 1. Upload the CSV file; the API finds the column with the addresses.
with open(path, "rb") as f:
response = requests.post(API, headers=HEADERS, files={"file": (os.path.basename(path), f)}, timeout=120)
response.raise_for_status()
job = response.json()
print(f"job {job['job_id']}: {job['counts']['unique']} unique addresses, {job['quota']['charged']} charged")
# 2. Poll about once a minute until the job has finished (large lists can take hours).
while job["status"] in ("queued", "processing"):
time.sleep(60)
response = requests.get(f"{API}/{job['job_id']}", headers=HEADERS, timeout=30)
response.raise_for_status()
job = response.json()
print(f"{job['status']} {job['progress']}%")
if job["status"] == "failed":
sys.exit(f"job failed: {job['error']}")
# 3. Download the results: your columns plus the emailvalidation_* columns.
response = requests.get(f"{API}/{job['job_id']}/results", headers=HEADERS, params={"format": "csv"}, timeout=120)
response.raise_for_status()
out = path.removesuffix(".csv") + ".verified.csv"
with open(out, "wb") as f:
f.write(response.content)
print("saved", out)
# 4. Delete the job and its stored list (otherwise it is deleted after 30 days).
requests.delete(f"{API}/{job['job_id']}", headers=HEADERS, timeout=30).raise_for_status()
Duplicates and rows without a valid address are free, and only unique, valid addresses are charged when you submit the file. The results file keeps your rows in their order and adds columns such as emailvalidation_state, emailvalidation_status and emailvalidation_; filter on the last one to get your send list. Instead of polling you can pass a webhook_url and receive one signed request when the job is done. The bulk documentation has the details. We ran the script against a local mock of the four bulk endpoints with a five-row file containing a duplicate and a malformed address.
Without code, the same works in the browser: the bulk email verifier checks a short list, and the email list cleaning service takes the whole file.
Recommended setup
- On input:
normalize_emailfor the format, with readable error messages. - Optionally
accepts_mail, if you want to catch dead domains without an API call. - On submit:
check_mailbox. Blockinvalidanddo_not_mail, accept the rest, and accept everything when the API can’t be reached. - For lists:
bulk.pyinstead of a loop over/v1/info.
The free plan includes 100 validations a month, and only definitive results (deliverable or undeliverable) count against it; email validation API pricing lists the paid plans. The same checks in other languages: JavaScript email validation, PHP email validation and Rails email validation.
Sources
Checked October 29, 2026: Python documentation, re