Blog
How to validate an email address in Python
What each library actually checks, why check_deliverability does not mean what its name suggests, and the one question none of them can answer.
VerifyInbox · published 11 September 2026 · 7 min read
Every example below was run on Python 3.13 with pydantic 2.13.5 and email-validator 2.3.0, and the output shown is what it printed.
Before any of it, the framing that makes the rest cohere — and the reason the usual answer to this question is incomplete:
| Layer | Question it answers | Can you do it locally? |
|---|---|---|
| Syntax | Is this well-formed? | Yes |
| Domain / MX | Can this domain receive mail at all? | Yes, with a DNS lookup |
| Mailbox | Does this specific mailbox exist? | No |
Methods 1 to 3 are all layer one, with an optional visit to layer two. If your actual question is "will this address bounce", none of them answers it, and the last section is about the one thing that does.
Method 1 — Pydantic v2 EmailStr
If Pydantic is already in the project, this is the answer. Note the extra: EmailStr is a thin wrapper and it will raise an import error without it.
pip install "pydantic[email]"from pydantic import BaseModel, EmailStr, ValidationError
class Signup(BaseModel):
email: EmailStr
print(Signup(email="[email protected]").email)
try:
Signup(email="jane.doe@@example.com")
except ValidationError as exc:
error = exc.errors()[0]
print(error["type"], "-", error["msg"])Output
[email protected]
value_error - value is not a valid email address: The part after the @-sign contains invalid characters: '@'.What it checks: syntax, through email-validator underneath. By default Pydantic does not perform the DNS check — EmailStr validates the form of the address and normalises it, and nothing else.
Best for: FastAPI request models, config validation, anywhere Pydantic is already present. The error message is unusually good, which matters when it ends up in a 422 body a front end has to render.
Method 2 — email-validator directly
The library Pydantic delegates to. Using it directly gets you the normalised form, the parsed parts, and control over whether it touches the network.
from email_validator import EmailNotValidError, validate_email
try:
result = validate_email("[email protected]", check_deliverability=False)
print("normalized:", result.normalized)
print("local part:", result.local_part)
print("domain: ", result.domain)
except EmailNotValidError as exc:
print("invalid:", exc)Output
normalized: [email protected]
local part: Jane.Doe
domain: example.comLook closely at the normalised value: the domain was lower-cased and the local part was not. That is correct and it is not a bug. Domains are case-insensitive; the local part is the receiving server’s business and the standard treats it as case-sensitive. A pipeline that lower-cases whole addresses to deduplicate them is making an assumption that is usually safe and occasionally wrong.
Now the parameter whose name causes most of the confusion this page exists to clear up:
from email_validator import EmailNotValidError, validate_email
for address in [
"[email protected]",
"[email protected]",
"[email protected]",
]:
try:
result = validate_email(address, check_deliverability=True)
print("OK ", address, "| mx:", result.mx)
except EmailNotValidError as exc:
print("FAIL", address, "->", exc)Output
OK [email protected] | mx: [(10, 'smtp.google.com')]
FAIL [email protected] -> The domain name example.com does not accept email.
FAIL [email protected] -> The domain name definitely-not-a-real-domain-xyz123.com does not exist.check_deliverability is a DNS check, not an SMTP probe. It asks whether the domain has mail servers. The second line above is a nice demonstration of precision: example.com is reserved by RFC 2606 and publishes a null MX, which is an explicit statement that it accepts no mail — so the library correctly reports that the domain does not accept email, rather than that it does not exist.
And the crucial thing the first line does not say: [email protected] passed, and there is almost certainly no such mailbox. The library told you Google can receive mail. It has no way to tell you whether that particular mailbox is there.
Best for: a codebase without Pydantic, and anywhere you want the normalised form as well as a yes/no. Keep check_deliverability=False in unit tests — otherwise your test suite makes DNS queries and fails on aeroplanes.
Method 3 — regex, and why it is a trap
Here is the pattern people come here for, and here is what it does to real addresses.
import re
NAIVE = re.compile(r"^[A-Za-z0-9._%-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$")
for address in [
"[email protected]",
"[email protected]",
"[email protected]",
]:
print(f"{address:32} {bool(NAIVE.match(address))}")Output
[email protected] True
[email protected] False
[email protected] FalseTwo rejections, both of perfectly valid addresses, both extremely common. jane+newsletter@ is plus addressing, which Gmail, Fastmail and most modern providers support and which a great many people use deliberately — rejecting it at signup tells a careful user that your form is broken. And {2,4} on the TLD was a reasonable assumption in 2005; .technology, .engineering and several hundred others have made it wrong ever since.
The rest of the list, briefly:
- Quoted local parts are legal.
"[email protected]"@example.comis a valid address and no readable pattern matches it. - Internationalised addresses exist. RFC 6530 defines UTF-8 in both halves, and an ASCII-only pattern rejects them all.
- The full grammar in RFC 5322 includes comments and folding whitespace. It is not practically expressible as one readable expression.
- A pattern with nested quantifiers can be made to backtrack catastrophically. Several well-known "email regexes" hang on a crafted input, which turns a validation routine into a denial-of-service vector.
The pragmatic ceiling for a client-side check is the pattern the HTML standard itself defines for input[type=email]. It is deliberately not RFC 5322 — the specification calls it a *willful violation* of the standard, adopted because the full grammar is impractical in a form field — and it is a reasonable place to stop.
If you are using a regular expression to decide whether to *send*, you are using the wrong tool. Use it to catch typos in a form field, and nothing else.
The gap: none of these tells you the mailbox exists
A well-formed address at a domain with valid MX records can be a mailbox that was deleted last year. No amount of local validation detects that, by construction: the only system that knows which mailboxes exist is the one that holds them.
This is not an edge case. The largest single cause of bounces on a business list is people leaving jobs, and every one of those addresses passes every check above on the day it stops working.
Method 4 — ask the mail server
The protocol answer is small: resolve the domain’s MX records, connect, say EHLO, give a sender with MAIL FROM, name the address with RCPT TO, read the reply, and disconnect before DATA. No message is transmitted, so nothing reaches the recipient.
And you should not implement it yourself, for a reason that has nothing to do with the code being hard. A new IP address opening thousands of short SMTP sessions is, at the protocol level, indistinguishable from a dictionary attack. Receiving providers throttle it, then block it — and from that point every answer the code produces is unknown at best and a false negative at worst. Accuracy at volume is a function of sending-IP reputation, warmup and pacing, not of the implementation.
So the fourth method is a call to something that maintains that infrastructure. The request and response shapes below are the ones in the API documentation; a verification costs half a credit, the free plan is 100 credits a month renewing, and a result marked retryable is not charged.
import os
import requests
response = requests.post(
"https://app.verifyinbox.tech/api/v1/verify",
headers={"Authorization": f'Bearer {os.environ["VERIFYINBOX_API_KEY"]}'},
json={"email": "[email protected]"},
timeout=30,
)
response.raise_for_status()
body = response.json()
print(body["status"], body["reason"])
print("smtp:", body["result"]["smtpCode"], body["result"]["smtpEnhancedCode"])
print("catch-all:", body["result"]["catchAll"], "retryable:", body["result"]["retryable"])The response is {id, email, status, reason, result}, where result is the engine’s full verdict passed through unchanged — emailStatus, reason, message, format, domainStatus, mailboxStatus, mailboxType, catchAll, disposable, freeProvider, roleAccount, smtpCode, smtpEnhancedCode, mxRecords, retryable and the rest. It is an open object on purpose, so a new signal reaches your code without waiting for a client release.
Handling the answers properly
This is the part that decides whether integrating verification helps or hurts, and it is four lines of logic.
| You get | Do |
|---|---|
status: "deliverable" | Send. |
status: "undeliverable" | Suppress permanently. Do not retry. |
status: "unknown" with retryable: true | Do not treat as invalid. Ask again later. You were not charged. |
status: "unknown" with reason: "catch_all" | Retrying will not change it — the domain accepts everything. Segment it away from confirmed addresses. |
HTTP 429 | Honour Retry-After. Limits come back in draft-7 rate limit headers. |
HTTP 503 with code: "ENGINE_CAPACITY" | No sending capacity was available, so the work was not done. Retry; you were not charged. |
The single most expensive mistake available here is writing an unknown into your database as invalid. It removes a real customer permanently and produces no signal that it happened — no bounce, no complaint, nothing. The verdict page has the full table of what produces one.
Where to put each layer
- Signup form, client side: syntax only, instantly. A network round trip inside a form field is a worse experience than the typo it catches.
- Form submission, server side: syntax and MX. Fast enough to run inline, and it catches every address at a domain that cannot receive mail.
- Background job or queue: the SMTP probe. It takes seconds, not milliseconds — it involves someone else’s server — so it does not belong in a request handler.
- Before a campaign: in bulk, close to the send. Verification describes the moment it ran, so the useful question is how long ago an address was confirmed relative to when you are about to mail it.
Terms used on this page
Sources
- RFC 5322 — Internet Message Format
- RFC 6530 — Overview and Framework for Internationalized Email
- HTML Standard — valid e-mail address
Primary sources checked 11 September 2026. If something here is out of date, tell us and we will correct it.
More: How to find someone's email address · Email validation regex: the pattern and its limits · Gmail, Yahoo and Microsoft sender requirements · How to block disposable email addresses