Tutorial · Python

Python email validation: format, DNS and mailbox checks

From a one-line format check to a verified CSV file: a tested regex, the email-validator package, an MX lookup with dnspython, a mailbox check in a FastAPI endpoint and a bulk job for whole lists.

Updated October 29, 2026 · 11 min read

Validating an email address in Python with the email-validator package

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.

AddressShould pass?Regexnormalize_emailEmailStr
jane@example.comYes✓✓✓
jane.doe+news@example.comYes✓✓✓
o'brien@example.ieYes✓✓✓
Anna@Example.COMYes✓✓ Anna@example.com✓ Anna@example.com
jane@xn--bcher-kva.exampleYes✓✓✓ jane@bücher.example
jane@bücher.exampleAfter IDN conversion✗✓ jane@xn--bcher-kva.example✓
jane.@example.comNo✗✗✗
jane..doe@example.comNo✗✗✗
jane@exampleNo✗✗✗
jane@example.cNo✗✓✓
jane@-example.comNo✗✗✗
jane@exa_mple.comNo✗✗✗
jane doe@example.comNo✗✗✗
"jane doe"@example.comNo (RFC-valid)✗✗✗
jane@[192.0.2.10]No (RFC-valid)✗✗✗
josé@example.comNo (needs SMTPUTF8)✗✗✓
aaa…a@example.com (65 a’s)No✗✗✓
jane@example.com with spaces around itAfter trimming✗✓✓
jane@mail.localNo (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 withaccepts_mail
Two MX recordsTrue
Null MX (0 .)False
A record onlyTrue
AAAA record onlyTrue
No such domain (NXDOMAIN)False
SERVFAILNone
No answerNone, 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:

statusExample sub_statusrecommended_actionWhat to do
validnull, role_based, possible_typosend, send_with_cautionAccept; show did_you_mean if it isn’t empty.
invalidmailbox_not_found, no_dns_entriessuppressReject.
catch_allaccept_allsend_with_cautionAccept or flag: the domain takes every address.
riskymailbox_fullretry_laterAccept; the mailbox is full right now.
do_not_maildisposablesuppressAsk for a permanent address.
unknowntimeout_exceeded, greylistedretry_laterAccept 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.Client(key).validate(email) 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 emailResponse
anna@example.org201
anna@example.org with spaces around it201, saved as anna@example.org
jane@bücher.example.com201, sent to the API as jane@xn--bcher-kva.example.com
jane@example422, “Please enter a valid email address. The part after the @-sign is not valid. It should have a period.”
josé@example.com422, with email-validator’s reason
An address the API reports as invalid422, “This email address doesn’t exist. Please check it.”
An address the API reports as do_not_mail422, “Please use a permanent email address.”
An address on a catch-all domain201
anna@gmial.com201 with "did_you_mean": "anna@gmail.com"
Any address while the API answers 429201, 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_recommended_action; 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.

  1. On input: normalize_email for the format, with readable error messages.
  2. Optionally accepts_mail, if you want to catch dead domains without an API call.
  3. On submit: check_mailbox. Block invalid and do_not_mail, accept the rest, and accept everything when the API can’t be reached.
  4. For lists: bulk.py instead 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.fullmatch; email-validator README; dnspython resolver documentation; Pydantic network types; Requests timeouts; RFC 7505 (null MX); emailvalidation.io API documentation: validation endpoint, bulk jobs, rate limits. Code run with Python 3.14, email-validator 2.3.0, dnspython 2.9.1, requests 2.34, Pydantic 2.14 and FastAPI 0.162, against a local DNS server and a local mock of the API.

Validate email in other languages

Frequently asked questions

How do I validate an email address in Python?

For the format, use the email-validator package (pip install email-validator) or a tested regex with re.fullmatch. Neither tells you whether the address exists. For sign-ups and imports, add a check of the domain’s MX records and a mailbox check, for example with an email validation API.

Which Python library validates email addresses?

email-validator is the common choice, and Pydantic’s EmailStr uses it. It checks the syntax, normalizes the address and, with check_deliverability=True, looks up the domain’s MX records. It doesn’t check whether the mailbox exists.

Why use re.fullmatch instead of re.match for email validation?

re.match only anchors the start, and a pattern that ends in $ also matches before a trailing line break, so "jane@example.com\n" passes. re.fullmatch requires the whole string to match.

Can I verify an email address with smtplib?

In theory you can open an SMTP connection and send RCPT TO. In practice outbound port 25 is blocked at most cloud providers, many servers greylist or accept every address (catch-all), and repeated probes can put your IP on a blocklist. An API runs these checks from its own infrastructure.

How do I validate a CSV file of email addresses in Python?

Upload the file to the bulk endpoint of an email validation API, poll the job until it is completed and download the results as CSV. The emailvalidation.io bulk API takes up to 100,000 rows per job and returns your columns plus the result columns.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "low_quality",
  "did_you_mean": "",
  "catch_all_resolution": null,
  "bounce_probability": null,
  "status": "valid",
  "sub_status": "role_based",
  "explanation": "The mailbox exists, but it is a role address (such as info@ or support@), usually read by a team rather than one person.",
  "recommended_action": "send_with_caution"
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free