Python Email Validation: email-validator, MX checks, and API (2026)
The complete guide to validating email addresses in Python. Real code for email-validator, DNS/MX checks with dnspython, and Truelist API integration.
TL;DR: For syntax and IDN handling, use the email-validator library. For deliverability — does this mailbox actually exist? — call a verification API. Don't write your own regex. Don't roll your own SMTP probe. Examples below cover both layers plus a FastAPI signup integration.
There are two different questions people mean when they say “validate an email in Python”:
- Is this string a syntactically valid email address? (Pure local check, sub-millisecond.)
- Is this email address actually deliverable? (Requires DNS lookups and ideally an SMTP probe; takes hundreds of milliseconds to tens of seconds.)
Conflating these is why so many signup forms ship with elaborate regex and still accept addresses that bounce the moment you email them. This guide covers both layers, with Python code that actually works in production.
Layer 1: Syntax validation with email-validator
Skip the regex. The Python ecosystem has a well-maintained library for this called email-validator that handles RFC 5322 syntax, internationalized domain names (IDN), and the dozen edge cases your regex won’t.
pip install email-validator Basic usage:
from email_validator import validate_email, EmailNotValidError
def is_valid_syntax(email: str) -> bool:
try:
validate_email(email, check_deliverability=False)
return True
except EmailNotValidError:
return False
is_valid_syntax("bob@example.com") # True
is_valid_syntax("bob@gmail") # False — no TLD
is_valid_syntax("bob @example.com") # False — whitespace in local
is_valid_syntax("漂亮的@测试.中国") # True — IDN handled Setting check_deliverability=False keeps this a pure local check. Useful when you want fast feedback (e.g. as a form-field validator) without making a network call.
What email-validator actually validates
Beyond surface RFC 5322:
- Local-part length (max 64 characters)
- Domain length (max 253 characters)
- Reserved domain names (
localhost,.test,.invalid) - Punycode/IDN normalization —
xn--80akhbyknj4f.comand its unicode form are normalized to the same canonical - Quoted local parts — supported per RFC 5321 but generally not allowed in practice
Normalizing the address
The validate_email call returns a ValidatedEmail object with normalized and ascii_email attributes. Always store the normalized version, not the raw input.
from email_validator import validate_email
result = validate_email(" Bob.Smith@EXAMPLE.com ", check_deliverability=False)
print(result.normalized) # bob.smith@example.com — lowercased domain, trimmed The local part isn’t lowercased by default (it’s case-sensitive per RFC 5321), but you can apply .lower() to the normalized result if your storage layer expects all-lowercase.
Layer 2: DNS / MX record check with dnspython
A syntactically valid email can still belong to a domain that doesn’t exist or doesn’t accept mail. The cheap fix is to verify the domain has MX records.
pip install dnspython import dns.resolver
def domain_has_mx(domain: str) -> bool:
try:
records = dns.resolver.resolve(domain, "MX")
return len(records) > 0
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return False
except dns.exception.Timeout:
return False # treat as "unknown" — your call
domain_has_mx("gmail.com") # True
domain_has_mx("not-a-real-domain.zz") # False You can combine layers 1 and 2 by letting email-validator do both:
from email_validator import validate_email, EmailNotValidError
def is_deliverable_domain(email: str) -> bool:
try:
validate_email(email) # check_deliverability=True by default
return True
except EmailNotValidError:
return False This does syntax check + MX lookup in one call. It’s still not a mailbox check, but it weeds out a huge fraction of typo’d domains (bob@gmial.com → no MX).
Why this still isn’t enough
DNS-level checks tell you the domain can receive mail. They don’t tell you:
- Whether the specific mailbox exists (
bob@gmail.commay resolve but the inbox could have been deleted) - Whether the domain is a catch-all that accepts everything
- Whether the address is on a known spam-trap list
- Whether the domain is a disposable service (Mailinator, Guerrilla Mail)
For those, you need to probe the mail server directly. Doing that yourself is technically possible — smtplib is in the standard library — but in practice it’s a tarpit. Servers greylist you, blocklist your IP after a few probes, and the false-positive rate is high. The pragmatic answer is to call a verification API.
Layer 3: Verification API with requests
A verification service runs syntax, MX, SMTP probe, disposable detection, role detection, and catch-all detection in a single call. Truelist exposes this through the verify_inline endpoint.
import os
import requests
def verify_email(email: str) -> dict:
response = requests.post(
"https://api.truelist.io/api/v1/verify_inline",
params={"email": email},
headers={
"Authorization": f"Bearer {os.environ['TRUELIST_API_KEY']}",
},
timeout=35, # full SMTP probe can take ~30s
)
response.raise_for_status()
return response.json()["emails"][0] The response is a dict with email_state and email_sub_state:
result = verify_email("bob@example.com")
print(result["email_state"]) # ok | email_invalid | risky | accept_all | unknown
print(result["email_sub_state"]) # email_ok | is_disposable | is_role | failed_mx_check | ... See the email validation API guide for the full response schema and the meaning of each value.
Sub-200ms feedback for signup flows
The full validation including SMTP can take up to 30 seconds. For signup forms that’s unacceptable. Skip SMTP on the synchronous call by passing the checks parameter:
def verify_fast(email: str) -> dict:
response = requests.post(
"https://api.truelist.io/api/v1/verify_inline",
params={
"email": email,
"checks": "syntax,mx,disposable,role",
},
headers={
"Authorization": f"Bearer {os.environ['TRUELIST_API_KEY']}",
},
timeout=5,
)
response.raise_for_status()
return response.json()["emails"][0] This typically returns in under 200ms. You can queue the full SMTP probe as a background job (Celery, RQ, ARQ, Dramatiq — your preference) and act on the result asynchronously.
Putting it together: FastAPI signup endpoint
A production-grade pattern that uses all three layers:
import os
from email_validator import validate_email, EmailNotValidError
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import httpx
app = FastAPI()
class SignupRequest(BaseModel):
email: str
password: str
@app.post("/api/signup")
async def signup(req: SignupRequest):
# Layer 1: cheap syntax check (sub-ms, no network)
try:
normalized = validate_email(req.email, check_deliverability=False).normalized
except EmailNotValidError as exc:
raise HTTPException(status_code=400, detail=f"Invalid email: {exc}")
# Layer 3 fast: deliverability without SMTP (sub-200ms)
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(
"https://api.truelist.io/api/v1/verify_inline",
params={
"email": normalized,
"checks": "syntax,mx,disposable,role",
},
headers={
"Authorization": f"Bearer {os.environ['TRUELIST_API_KEY']}",
},
)
if response.status_code >= 500:
# Fail open: accept signup, queue SMTP check as background job
pass
else:
result = response.json()["emails"][0]
if result["email_state"] == "email_invalid":
raise HTTPException(
status_code=400,
detail="That email address cannot receive mail.",
)
if result["email_sub_state"] == "is_disposable":
raise HTTPException(
status_code=400,
detail="Disposable email addresses are not supported.",
)
user_id = await create_user(normalized, req.password)
await enqueue_full_verification(user_id, normalized)
return {"ok": True, "user_id": user_id} A few patterns worth noting:
async with httpx.AsyncClient—requestsis fine for sync code, but FastAPI is async, so usehttpxto avoid blocking the event loop on the verification call.- Fail open on 5xx — if Truelist (or any upstream verification service) has an incident, your signup flow should keep working. Queue the verification for later instead of rejecting the user.
- Store the normalized address — the result from
email-validator, not the raw input from the form. - Queue async, not synchronous — never call the full SMTP-probe endpoint from a request handler. Always run it in a background worker.
Pydantic v2 patterns for FastAPI
If you’re on FastAPI, you’re on Pydantic — and Pydantic v2 ships with a handful of patterns that make the layered approach above cleaner. The two worth knowing are EmailStr (a built-in type) and the BeforeValidator / AfterValidator decorators (custom validators wired into the type system).
EmailStr requires the email-validator extra:
pip install "pydantic[email]" Then the syntax check happens automatically at request parsing:
from pydantic import BaseModel, EmailStr
class SignupRequest(BaseModel):
email: EmailStr
password: str A request with "email": "bob@gmail" fails parsing before your handler runs and FastAPI returns a 422 with a structured error. No try/except needed. The downside: EmailStr only does syntax. It doesn’t normalize, doesn’t strip whitespace, and doesn’t do the MX check.
For a richer pipeline — normalize on input, deliverability check on save — use a custom Annotated type:
from typing import Annotated
from email_validator import validate_email, EmailNotValidError
from pydantic import AfterValidator, BaseModel
def normalize_and_validate(value: str) -> str:
try:
return validate_email(value, check_deliverability=False).normalized
except EmailNotValidError as exc:
raise ValueError(str(exc))
NormalizedEmail = Annotated[str, AfterValidator(normalize_and_validate)]
class SignupRequest(BaseModel):
email: NormalizedEmail
password: str Now SignupRequest(email=" Bob@EXAMPLE.com ").email is "bob@example.com". The AfterValidator runs after Pydantic’s own string coercion, so you don’t have to worry about non-string input.
Typing the verification response
If you want type hints on the dict you get back from Truelist, declare it with TypedDict:
from typing import Literal, TypedDict
class VerifiedEmail(TypedDict):
email: str
email_state: Literal["ok", "email_invalid", "risky", "accept_all", "unknown"]
email_sub_state: str
is_disposable: bool
is_role: bool Cast the response and your IDE will autocomplete the fields. This is also handy if you’ve wrapped the verification call in a service layer and want to expose a typed contract to the rest of the codebase.
Django integration
Django’s built-in EmailField on both forms and models does basic syntax validation but skips deliverability entirely. The clean integration point is a custom validator.
For a Django form:
from django import forms
from email_validator import validate_email, EmailNotValidError
def validate_deliverable_email(value):
try:
validate_email(value) # syntax + MX
except EmailNotValidError as exc:
raise forms.ValidationError(str(exc))
class SignupForm(forms.Form):
email = forms.EmailField(validators=[validate_deliverable_email])
password = forms.CharField(widget=forms.PasswordInput) For Django REST Framework, the same idea wired into a serializer:
from rest_framework import serializers
class SignupSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
def validate_email(self, value):
try:
return validate_email(value, check_deliverability=False).normalized
except EmailNotValidError as exc:
raise serializers.ValidationError(str(exc)) The pattern is the same as FastAPI: syntax + normalize in the validator, full deliverability check (Truelist call) inside the view or in a background task triggered after serializer.save(). Don’t put the network call inside the serializer — it makes testing miserable and ties request latency to an external service.
Flask-WTF integration
Flask-WTF wires email-validator automatically when you use Email():
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Email
class SignupForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email(check_deliverability=True)])
password = PasswordField("Password", validators=[DataRequired()]) The check_deliverability=True flag delegates the MX check to email-validator under the hood. For the API-level deliverability check, add a second custom validator:
from wtforms import ValidationError
import requests, os
def truelist_check(form, field):
response = requests.post(
"https://api.truelist.io/api/v1/verify_inline",
params={"email": field.data, "checks": "syntax,mx,disposable,role"},
headers={"Authorization": f"Bearer {os.environ['TRUELIST_API_KEY']}"},
timeout=5,
)
if response.ok:
result = response.json()["emails"][0]
if result["email_state"] == "email_invalid":
raise ValidationError("That email cannot receive mail.") Wire it into the form by adding truelist_check to the validators list on the email field.
Async deliverability for bulk lists
The single-address pattern is one thing. Validating a CSV of 50,000 leads is another — and this is where async Python pays off. Run the verifications concurrently with asyncio and aiohttp, capped by a semaphore so you don’t fire 50,000 requests in parallel.
import asyncio
import aiohttp
import os
API_URL = "https://api.truelist.io/api/v1/verify_inline"
HEADERS = {"Authorization": f"Bearer {os.environ['TRUELIST_API_KEY']}"}
async def verify_one(session: aiohttp.ClientSession, email: str, sem: asyncio.Semaphore):
async with sem:
async with session.post(
API_URL,
params={"email": email, "checks": "syntax,mx,disposable,role"},
headers=HEADERS,
timeout=aiohttp.ClientTimeout(total=10),
) as response:
data = await response.json()
return data["emails"][0]
async def verify_many(emails: list[str], concurrency: int = 20) -> list[dict]:
sem = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession() as session:
tasks = [verify_one(session, e, sem) for e in emails]
return await asyncio.gather(*tasks, return_exceptions=True)
# Usage
results = asyncio.run(verify_many(load_emails_from_csv("leads.csv"))) A few notes on this pattern. The semaphore caps in-flight requests so you don’t trip rate limits. return_exceptions=True keeps a single failure from cancelling the whole batch — you’ll get an Exception object in the result list at that position instead. For lists over a few thousand emails, use the Truelist batch endpoint instead; it’s purpose-built for volume and includes its own backpressure.
The 2026 angle: validating LLM-generated user data
A pattern showing up everywhere this year: AI assistants extracting contact info from documents or natural-language input and writing it to your CRM. The email field is whatever the model hallucinated — treat it like raw user input.
LLMs are particularly good at producing addresses that look right but bounce: slightly-wrong domains like gmial.com, invented company emails, or addresses lifted from stale training data. Always run syntax + normalization first, then the API-level deliverability check before persisting. If you’re using Pydantic with the OpenAI or Anthropic SDKs for structured output, you can wire the NormalizedEmail type from the FastAPI section directly into the response model:
from pydantic import BaseModel
class ExtractedContact(BaseModel):
name: str
email: NormalizedEmail # raises if not a valid email
company: str | None = None Parsing fails automatically when the model returns garbage. For background on the underlying deliverability signals, see the MX record lookup guide and our notes on why emails bounce.
If you’re working inside an AI assistant — Claude Desktop, Claude Code, Cursor, or VS Code Copilot — you can also validate emails conversationally via the Truelist MCP server. It’s the same deliverability stack as the Python SDK, but driven by natural-language tool calls and OAuth (no API key to paste into your notebook). Useful for ad-hoc cleanup runs and prototyping batch jobs before promoting them into production code.
Common pitfalls
A few gotchas Python developers hit:
re.matchvsre.fullmatch—re.matchonly checks the start of the string. If you write your own regex (don’t), usere.fullmatchso trailing junk isn’t accepted.- Hidden whitespace —
bob@example.com\nwill pass naive checks.email-validatorstrips this; raw regex usually doesn’t. - IDN handling —
validate_emailreturns both.normalized(unicode form) and.ascii_email(Punycode). Use ASCII when storing for SMTP submission; use the unicode form for UI display. - Plus-addressing —
bob+newsletter@gmail.comis a valid Gmail address. Don’t strip the+part. Faker-generated emails in tests — they’re random strings; many won’t pass full validation. Usecheck_deliverability=Falsein test fixtures.
When to use what
| Need | Use |
|---|---|
| Form-field validator, no network | email-validator with check_deliverability=False |
| Reject obviously-bad domains on submit | email-validator default (syntax + MX) |
| Reject disposable/role-based for signup | Truelist verify_inline with checks=syntax,mx,disposable,role |
| Catch undeliverable addresses, full check | Truelist verify_inline (default, with SMTP) — run async |
| Bulk-validate a CSV | Truelist batch API (separate endpoint, designed for volume) |
| Validate from inside Claude, Cursor, or Copilot | Truelist MCP server — no API key required |
For the language-agnostic API guide, see email validation API. For JavaScript-side validation, see JavaScript email validation. For PHP, see PHP email verification. For deeper coverage of what counts as a valid address, is this email valid? walks through the underlying checks, and email address existence checker explains the deliverability layer.
FAQ
Should I still use a regex for email validation in Python?
No. The official email-validator library is well-maintained, handles internationalized domains, and catches edge cases regex misses (length limits, reserved TLDs, Punycode normalization). The only reason to write your own regex is a learning exercise, and even then, RFC 5322 in regex is famously hundreds of characters long.
Is email.utils.parseaddr from the standard library enough?
email.utils.parseaddr parses an address out of a “Name addr@example.com” string but doesn’t validate it. It will happily return ("", "garbage") for invalid input. Use it for parsing, then hand the result to email-validator for validation.
How do I validate emails without making network calls in tests?
Pass check_deliverability=False to validate_email. This skips the MX lookup entirely. For tests that need to exercise the deliverability path, mock the Truelist client or use VCR.py to record and replay HTTP fixtures.
What’s the difference between validate_email from email-validator and from pydantic?
Pydantic’s EmailStr uses email-validator under the hood — same library, different surface. Use EmailStr when you want Pydantic to handle the error reporting (request returns 422 automatically). Use validate_email directly when you want control over the error path or need access to the .normalized and .ascii_email attributes.
Can I check if a Gmail address actually exists without sending an email?
Not reliably. Gmail (and most modern providers) accept SMTP probes for any address and reject undeliverable ones at the message level instead of at handshake. A verification API gets around this by combining MX checks, disposable-domain lists, role-account detection, and probe patterns that haven’t been blocked yet. For a deeper explanation, see email address existence checker.
How do I rate-limit my own application’s verification calls?
If you’re worried about hitting Truelist limits or just want to be polite, wrap your verifier in an asyncio.Semaphore (shown in the bulk section above) or use the aiolimiter package for a token-bucket rate limiter. Most production setups put a Redis-backed limiter in front of the verification service so multiple workers share the budget.
