mrkeyoor.com_
Thu 06 Aug 01:00 UTC
PyPIWeb Backendupdated 05 Aug 2026

email-validator

email-validator checks that a string is a usable email address and hands you back a cleaned-up version of it. You call validate_email(address) and either get a ValidatedEmail object with a normalized field you should store in your database, or an EmailNotValidError whose message is written in plain English so you can show it to the user. It also optionally does a DNS lookup to see whether the domain has an MX record that can receive mail. The library is deliberately opinionated: it rejects @localhost, domains with no dot, the obsolete (comment) syntax, and unsafe Unicode, even though some of those are technically legal per the RFCs. It is the package pydantic installs when you use EmailStr.

Verdict

The default Python answer for validating an address at a form boundary, mostly because it does normalization and internationalization correctly and gives you an error message you can print. Turn check_deliverability off everywhere except account creation, and remember it proves nothing about whether the mailbox exists.

API stability5/5The 2.x line has held validate_email plus keyword options since April 2023; changes since then have been new opt-in flags (allow_display_name, strict) and stricter Unicode handling, not signature churn.
Docs4/5The README is unusually candid: it documents every option, lists the exact returned fields in a table, explains why obsolete syntaxes are rejected, and warns you off @example.com in tests. There is no separate docs site and the README sometimes runs ahead of the published release.
Maintenance3/5Pushed June 2026 with 12 open issues (16 counting PRs), and a CHANGELOG that shows careful security-minded fixes. It is a one-person project by Joshua Tauberer, and 2.3.0 in August 2025 was the only release in about two years.
Ecosystem4/5Pulled in transitively across the web stack because pydantic's EmailStr depends on it, which is where most of the download volume comes from; it is a leaf library, so there are no plugins to speak of.

Use it if

  • You have a signup form and want one call that catches typo'd addresses, gives a user-facing error string, and returns the exact form you should write to the users table
  • You accept international addresses and need IDNA 2008 handling, Punycode conversion (ascii_email), Unicode NFC normalization, and a flag telling you whether delivery needs SMTPUTF8
  • You want an optional cheap deliverability signal at registration time: does the domain resolve, does it have a non-null MX record, is there a reject-all SPF record
  • You already use pydantic's EmailStr, since that field type is a thin wrapper over this library and configuring it means configuring email-validator
Skip it if

Setup reality

pip install email-validator brings in dnspython and idna, both pure Python, so there is nothing to compile. The real trap is the default: check_deliverability=True means DNS on every call, which makes tests slow and flaky offline and turns a login handler into a network call. Set email_validator.CHECK_DELIVERABILITY = False globally or pass it per call. The second trap is test data. @example.com and @localhost both fail out of the box: localhost is in SPECIAL_USE_DOMAIN_NAMES, and example.com resolves but does not accept mail, so use test_environment=True with @something.test in fixtures. Third, if you validate in a loop, build one caching_resolver() and pass it in as dns_resolver or you re-query the same domains forever.

Patterns

Validate an address and store the normalized formvalidate-at-signup

from email_validator import validate_email, EmailNotValidError

try:
    info = validate_email(raw_input_address)
    email = info.normalized   # store THIS
except EmailNotValidError as e:
    return {"error": str(e)}, 400

Always persist info.normalized, not the raw input. The domain is lowercased and Unicode is NFC-normalized, so the raw string and the normalized one can differ and your later lookups will miss.

Turn off the DNS check on hot pathsskip-dns-on-login

info = validate_email(email, check_deliverability=False)

# or globally, once at startup:
import email_validator
email_validator.CHECK_DELIVERABILITY = False

check_deliverability defaults to True, so every call does DNS with a 15 second default timeout. Use it on account creation only; on login it just adds latency and a new failure mode.

Reuse one resolver when validating many addressescaching-resolver

from email_validator import validate_email, caching_resolver

resolver = caching_resolver(timeout=10)
for address in addresses:
    try:
        validate_email(address, dns_resolver=resolver)
    except Exception as e:
        print(address, e)

Without a shared resolver each call re-queries DNS even for the same domain. caching_resolver builds a dns.resolver.Resolver with an LRUCache; pass the same instance every time or the cache does nothing.

Allow .test addresses in fixturestest-environment

info = validate_email("user@myapp.test", test_environment=True)

# or globally in conftest.py:
import email_validator
email_validator.TEST_ENVIRONMENT = True

test_environment=True disables DNS and permits test and *.test. Do not reach for @example.com instead: it is not special-cased here and fails the deliverability check because it accepts no mail.

Tell a typo apart from a dead domainseparate-syntax-from-dns

from email_validator import (validate_email, EmailSyntaxError,
                             EmailUndeliverableError)

try:
    validate_email(email)
except EmailSyntaxError as e:
    flash(f"That address looks wrong: {e}")
except EmailUndeliverableError as e:
    flash(f"We could not reach that domain: {e}")

Both subclass EmailNotValidError, which subclasses ValueError. Treat undeliverable as soft: DNS timeouts and temporary failures land here, so blocking signup on it will lock out real users.

Handle internationalized addresses and SMTPUTF8international-addresses

info = validate_email("example@\u30c4.life", check_deliverability=False)
print(info.normalized)    # example@\u30c4.life
print(info.ascii_email)   # example@xn--bdk.life
print(info.smtputf8)      # False

# reject anything your mail stack cannot send:
validate_email(addr, allow_smtputf8=False)

ascii_email is None when the local part itself is non-ASCII, because only the domain can be Punycoded. If your SMTP relay lacks SMTPUTF8, pass allow_smtputf8=False and get a syntax error up front instead of a bounce later.

Accept "My Name <me@example.org>" inputparse-display-name

info = validate_email('"My Name" <me@example.org>',
                      allow_display_name=True,
                      check_deliverability=False)
print(info.display_name)  # My Name
print(info.normalized)    # me@example.org

Off by default. display_name is None when there were no angle brackets and an empty string when there were brackets but no name, so test for None explicitly rather than truthiness.

Read the MX records the check foundinspect-mx-records

info = validate_email("user@gmail.com")
print(info.mx)                 # [(5, 'gmail-smtp-in.l.google.com'), ...]
print(info.mx_fallback_type)   # None if a real MX record exists

mx can be None even on success when the lookup hit a temporary problem such as a timeout. mx_fallback_type is 'A' or 'AAAA' when the domain has no MX record and delivery falls back to the address record.

Opt back into quoted local parts and domain literalsallow-obsolete-forms

info = validate_email('"weird name"@example.com',
                      allow_quoted_local=True,
                      check_deliverability=False)

info2 = validate_email("user@[192.168.1.1]",
                       allow_domain_literal=True)
print(info2.domain_address)   # IPv4Address('192.168.1.1')

Both are rejected by default on purpose. Domain literals get no deliverability check at all, and quoted local parts are normalized (unnecessary escapes and quotes stripped), so the stored value may not match the input string.

Enforce the 64 character local part limitstrict-length-check

# 2.3.0 stopped enforcing this by default; restore it if you
# are the one issuing the mailboxes:
info = validate_email(address, strict=True, check_deliverability=False)

New in 2.3.0. The library dropped the local part length check because addresses longer than 64 characters exist in the wild; the overall 254 character address limit is still enforced either way.

Use it through pydantic's EmailStrpydantic-emailstr

from pydantic import BaseModel, EmailStr

class Signup(BaseModel):
    email: EmailStr

# requires: pip install "pydantic[email]"

EmailStr is a wrapper over this library and does syntax only, no DNS. If you want a deliverability check in a pydantic model you have to call validate_email yourself in a field validator.

Check addresses from the shellcli-spot-check

python -m email_validator test@example.org

# or pipe a list, one address per line; only invalid ones print
python -m email_validator < addresses.txt

Options map to uppercase environment variables, so CHECK_DELIVERABILITY= is not how you disable it; the tool reads the variable as a bool, and any non-empty value including "0" counts as True.

Alternatives

PackageRegistryPick it when
pyIsEmailPyPIYou want closer RFC conformance and a diagnosis code per address instead of one opinionated pass or fail.
validatorsPyPIYou just need a boolean syntax check for an email, URL, or IP inside a script and do not want DNS anywhere near it.
pydanticPyPIYour validation already lives in a model layer; EmailStr wraps this same library and gives you request-body validation for free.
email-normalizePyPIYour problem is deduplicating addresses across providers (gmail dots and plus tags) rather than deciding validity.