Email Validation in Python — Beyond Regex
A regex can check the format, but it cannot tell you whether the domain accepts email, whether the address is disposable, or whether it will actually deliver. Here's the full picture — with Python code you can drop into any project.
In this guide
- 1. Why regex alone is not enough
- 2. What proper email validation involves
- 3. MX record lookups — how they work and why they matter
- 4. Disposable domains — what they are and why to block them
- 5. The right solution: one API call
- 6. Python code example
- 7. cURL examples
- 8. Understanding the response
- 9. Edge cases — plus addressing, unicode domains, role addresses
1. Why regex alone is not enough
RFC 5322 defines the email format, and the full spec is surprisingly permissive. The most common mistake is using a regex that is either too strict (rejecting valid addresses) or too loose (accepting invalid ones).
import re # ❌ Too strict — rejects legitimate addresses SIMPLE_REGEX = re.compile(r'^[a-z0-9.]+@[a-z0-9]+\.[a-z]{2,}$', re.IGNORECASE) print(SIMPLE_REGEX.match('user+tag@example.com')) # None — + is valid print(SIMPLE_REGEX.match('"john doe"@example.com')) # None — quoted local part is valid print(SIMPLE_REGEX.match('user@sub.domain.co.uk')) # None — multiple dots in domain print(SIMPLE_REGEX.match('user@xn--nxasmq6b.com')) # None — IDN domains are valid # ❌ Too loose — accepts clearly invalid addresses LOOSE_REGEX = re.compile(r'^.+@.+$') print(LOOSE_REGEX.match('@domain.com')) # Match ✗ — no local part print(LOOSE_REGEX.match('user@')) # Match ✗ — no domain print(LOOSE_REGEX.match('user@domain')) # Match ✗ — no TLD
Addresses that are technically valid per RFC but almost never used in practice:
| Address | Why it's valid |
|---|---|
| "very.unusual address"@example.com | Quoted local part allows spaces |
| user@[192.168.1.1] | IP address literal as domain |
| user+label@example.com | Plus addressing — widely used for filtering |
| user@münchen.de | Internationalised domain (IDN) |
email.utils.parseaddr() can extract the address from a "Name <addr>" string, but it does not validate format. It will happily return garbage local parts and domains with no TLD."very.unusual address"@example.com), IP address literals (user@[192.168.1.1]), and internationalised local parts — these are technically RFC-valid but virtually never encountered in real-world email systems.2. What proper email validation involves
Email validation is not a single check — it is a stack of progressively stricter tests, each catching a different class of problem:
Syntax check (format)
Does the string look like an email? Local part, @, domain, TLD. Fast, no network call.
Free — run always
MX record lookup (domain deliverability)
Does the domain have mail exchange records? A format-valid email on a domain with no MX will always bounce.
~50-200ms DNS lookup
Disposable domain detection
Is the domain a known throwaway provider like Mailinator or Guerrilla Mail? Format-valid, MX-valid, but not a real user.
Database lookup
The IsValid Email API handles all three in a single request — format validation, live MX record lookup, and disposable domain detection. No need to maintain your own regex, DNS resolver, or disposable domain list.
3. MX record lookups — how they work and why they matter
An MX (Mail Exchanger) record is a DNS entry that tells the internet which servers handle email for a domain. If a domain has no MX records, email sent there will bounce — even if the address has a perfect format.
Common failure modes
- •Typo in domain —
user@gmial.comformats correctly, passes regex, butgmial.comhas no MX record. - •Expired domain — a company lets its domain lapse; old email addresses format-validate but no longer deliver.
- •New TLD with no setup —
user@company.iois valid but the domain may not have email configured even if the website works.
DIY MX lookup in Python
import dns.resolver # pip install dnspython def has_mx_record(domain: str) -> bool: """Check if a domain has at least one MX record.""" try: answers = dns.resolver.resolve(domain, "MX") return len(answers) > 0 except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers, dns.exception.Timeout): return False print(has_mx_record("gmail.com")) # True print(has_mx_record("gmial.com")) # False — typo print(has_mx_record("example.com")) # False — reserved, no MX
This works but adds latency and a dependency (dnspython). Cache results per domain to avoid repeated DNS lookups for the same domain.
4. Disposable domains — what they are and why to block them
Services like Mailinator, Temp-Mail, Guerrilla Mail, and hundreds of others provide temporary email addresses that expire after minutes or hours. These addresses are format-valid, often have valid MX records, and will deliver email — but they are used to bypass registration requirements and will never be owned by a real user long-term.
Why users use them
- -Avoid marketing spam
- -One-time coupon codes
- -Trial account bypasses
- -Testing your own forms
Why you may want to block them
- -Invalid for long-term contact
- -Used for abuse / promo fraud
- -Inflate registration numbers
- -No GDPR-compliant contact basis
5. The right solution: one API call
The IsValid Email API handles RFC 5322 format validation, live DNS MX lookup, and disposable domain detection in a single request. With the optional checkMx=true parameter it performs a live MX record check and returns the result alongside the parsed local part, domain, and disposable status.
Full parameter reference and response schema: Email Validation API docs →
6. Python code example
Using the popular requests library. Install it with pip install requests.
# email_validator.py import os import requests API_KEY = os.environ["ISVALID_API_KEY"] BASE_URL = "https://api.isvalid.dev" def validate_email(email: str, *, check_mx: bool = False) -> dict: """ Validate an email address using the IsValid API. Returns a dict with keys: valid, local, domain, disposable, mx. Raises requests.HTTPError on non-2xx responses. """ params = {"value": email} if check_mx: params["checkMx"] = "true" response = requests.get( f"{BASE_URL}/v0/email", params=params, headers={"Authorization": f"Bearer {API_KEY}"}, timeout=5, ) response.raise_for_status() return response.json() # ── Example usage ───────────────────────────────────────────────────────────── result = validate_email("user@example.com") if result["valid"]: print(f"Domain: {result['domain']}") print(f"Disposable: {result['disposable']}") print(f"MX records: {result['mx']}") # With MX check (confirms domain receives email) with_mx = validate_email("alice@gmial.com", check_mx=True) print(with_mx["valid"]) # True — format is valid print(with_mx["mxValid"]) # False — gmial.com has no MX record
In a Django or Flask registration handler — validate format immediately, check MX on submit:
# views.py (Django) from django.http import JsonResponse import requests as http_client def register(request): email = request.POST.get("email", "").strip() password = request.POST.get("password", "") if not email or not password: return JsonResponse({"error": "Email and password required"}, status=400) try: check = validate_email(email, check_mx=True) except http_client.Timeout: return JsonResponse({"error": "Email validation service timeout"}, status=502) except http_client.HTTPError as exc: return JsonResponse({"error": str(exc)}, status=502) if not check["valid"]: return JsonResponse({"error": "Invalid email format"}, status=400) if check.get("mxValid") is False: return JsonResponse({ "error": "Email domain does not appear to accept mail. Check for typos.", }, status=400) if check.get("disposable"): return JsonResponse({ "error": "Disposable email addresses are not allowed.", }, status=400) # Proceed with account creation create_user(email=email, password=password) return JsonResponse({"success": True})
Flask equivalent with WTForms custom validator:
# forms.py (Flask + WTForms) from wtforms import Form, StringField, PasswordField from wtforms.validators import DataRequired, ValidationError class RegistrationForm(Form): email = StringField("Email", validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()]) def validate_email(self, field): """Custom WTForms validator using the IsValid API.""" try: result = validate_email(field.data, check_mx=True) except Exception: raise ValidationError("Email validation service unavailable") if not result["valid"]: raise ValidationError("Invalid email address") if result.get("mxValid") is False: raise ValidationError("This domain does not accept email") if result.get("disposable"): raise ValidationError("Disposable email addresses are not allowed")
7. cURL examples
Format check only (fast, no DNS):
curl -G -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "value=alice@example.com" \ "https://api.isvalid.dev/v0/email"
With MX record lookup:
curl -G -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "value=alice@gmial.com" \ "https://api.isvalid.dev/v0/email?checkMx=true"
Test with a plus-addressed email:
curl -G -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "value=alice+newsletter@gmail.com" \ "https://api.isvalid.dev/v0/email?checkMx=true"
8. Understanding the response
Format valid, MX check requested and passed, not disposable:
{ "valid": true, "local": "alice", "domain": "gmail.com", "disposable": false, "mx": true, "mxValid": true }
Format valid, domain has no MX records (likely a typo):
{ "valid": true, "local": "alice", "domain": "gmial.com", "disposable": false, "mx": false, "mxValid": false }
Format valid, disposable domain detected:
{ "valid": true, "local": "temp123", "domain": "mailinator.com", "disposable": true, "mx": true, "mxValid": true }
Format invalid:
{ "valid": false }
| Field | Type | Description |
|---|---|---|
| valid | boolean | RFC 5322 format is correct |
| local | string | Everything before the @ sign |
| domain | string | Everything after the @ sign |
| disposable | boolean | true if the domain is a known disposable email provider (Mailinator, Guerrilla Mail, Temp-Mail, etc.) |
| mx | boolean | Whether the domain has at least one MX record |
| mxValid | boolean | Present only when checkMx=true. true if at least one MX record was found for the domain. |
9. Edge cases — plus addressing, unicode domains, role addresses
Plus addressing (+tag) and sub-addressing
alice+newsletter@gmail.com is a valid address that delivers to alice@gmail.com. Many users use this for filtering. Do not reject plus-addressed emails — they are legitimate. Normalising them (stripping the tag) is a policy choice with privacy implications.
def strip_plus_tag(email: str) -> str: """Strip the +tag portion from a plus-addressed email. Warning: this is a policy decision — consider the privacy implications before normalising user emails. """ local, domain = email.rsplit("@", 1) if "+" in local: local = local.split("+", 1)[0] return f"{local}@{domain}" print(strip_plus_tag("alice+newsletter@gmail.com")) # alice@gmail.com print(strip_plus_tag("alice@gmail.com")) # alice@gmail.com
Unicode domains (IDN)
Internationalised Domain Names like user@münchen.de are valid and get encoded as Punycode (xn--mnchen-3ya.de) in DNS. Python's idna library handles encoding, but if you send the raw unicode domain to a naive regex, it will fail.
# pip install idna import idna def encode_idn_domain(email: str) -> str: """Convert a unicode domain to its ASCII (Punycode) form.""" local, domain = email.rsplit("@", 1) try: ascii_domain = idna.encode(domain).decode("ascii") except idna.IDNAError: return email # return as-is if encoding fails return f"{local}@{ascii_domain}" print(encode_idn_domain("user@münchen.de")) # user@xn--mnchen-3ya.de
Role addresses (admin@, info@, postmaster@)
Addresses like admin@, info@, postmaster@, noreply@, and support@ are role-based addresses that are format-valid and often have MX records. Whether you want to accept them depends on your use case. For user registration, role addresses usually indicate a shared inbox rather than an individual account — you may want to flag them but not necessarily reject them outright.
Suggest corrections for common typos
When mxValid is False, show the user a helpful message rather than a generic error. If you detect a domain that looks like a typo of a popular provider, suggest the correction.
TYPOS: dict[str, str] = { "gmial.com": "gmail.com", "gmal.com": "gmail.com", "gamil.com": "gmail.com", "yahooo.com": "yahoo.com", "yaho.com": "yahoo.com", "hotmial.com": "hotmail.com", "hotmal.com": "hotmail.com", "outlok.com": "outlook.com", "outloo.com": "outlook.com", } def suggest_correction(domain: str) -> str | None: """Suggest a corrected domain for common typos.""" return TYPOS.get(domain.lower()) # Usage after validation result = validate_email("alice@gmial.com", check_mx=True) if result.get("mxValid") is False: suggestion = suggest_correction(result["domain"]) if suggestion: local = result["local"] print(f"Did you mean {local}@{suggestion}?") else: print("This domain does not accept mail. Please check for typos.")
Case sensitivity
Per RFC 5321, the local part of an email is technically case-sensitive — Alice@example.com and alice@example.com are different addresses. In practice, virtually all mail servers treat the local part as case-insensitive. Lowercase the local part before storing to avoid duplicate accounts: email.lower().strip().
Never block on MX failure in all contexts
MX failures can be transient (DNS outage, propagation delay for a new domain). Consider showing a soft warning rather than a hard block, especially for B2B customers who might be entering a newly set-up company domain. The decision to hard-block depends on your fraud risk appetite.
Summary
See also
Validate email addresses instantly
Free tier includes 100 API calls per day. No credit card required. Format check under 10ms, optional MX lookup under 200ms, disposable domain detection included.