BookMySpot

Developer Guides

How to Validate an Email Address in Python (email-validator, Regex, and DNS)

The most reliable way to validate an email address in Python is the email-validator package: it checks syntax against the standards, normalizes the address, and can optionally confirm the domain is set up to receive mail. A regex works when you can’t add a dependency, and dnspython lets you run the MX lookup yourself. None of these can prove a mailbox exists; for that you need a confirmation email. Every example below runs on Python 3.12 and later.

The short answer: use email-validator

email-validator is a maintained library built for exactly this job. Version 2.3.0 is the current release on PyPI as of this writing. Install it:

python -m pip install email-validator

Basic use:

from email_validator import validate_email, EmailNotValidError

def clean_email(raw: str) -> str | None:
    try:
        info = validate_email(raw, check_deliverability=False)
    except EmailNotValidError as exc:
        print(f"Rejected {raw!r}: {exc}")
        return None
    return info.normalized

print(clean_email("User@Example.COM"))        # User@example.com
print(clean_email("user@localhost"))          # Rejected ... It should have a period.
print(clean_email("jane..doe@example.com"))   # Rejected ... two periods in a row.
print(clean_email('"john doe"@example.com'))  # Rejected ... Quoting ... is not allowed here.

A few things stand out:

  • Errors are human-readable. str(exc) gives messages like “The part after the @-sign is not valid. It should have a period.” You can show these to users directly.
  • Normalization. info.normalized lowercases the domain and applies Unicode normalization. It keeps the local part’s case (User@), because RFC 5321 section 2.4 says the local part must be treated as case-sensitive. The project’s README recommends storing the normalized form and normalizing before any database lookup.
  • Opinionated defaults. By default it rejects quoted local parts, IP-address domains, display names like Jane <jane@example.com>, and dotless domains. Options such as allow_quoted_local, allow_domain_literal, and allow_display_name loosen those rules if you need them.

Turning on the deliverability check

With check_deliverability=True (the library’s default), email-validator also queries DNS. Per the README, it looks for MX records, rejects a null MX, and if there’s no MX it accepts a fallback A or AAAA record unless the domain publishes an SPF record that rejects all mail.

from email_validator import validate_email, EmailNotValidError, caching_resolver

# Reuse one resolver so repeated domains come from cache. Default timeout is 15 seconds.
resolver = caching_resolver(timeout=5)

for addr in ["someone@gmail.com", "someone@example.com", "x@no-such-domain-zzzz-12345.com"]:
    try:
        info = validate_email(addr, check_deliverability=True, dns_resolver=resolver)
        print("OK ", info.normalized, info.mx[:2])
    except EmailNotValidError as exc:
        print("BAD", addr, "-", exc)

Output when run:

OK  someone@gmail.com [(5, 'gmail-smtp-in.l.google.com'), (10, 'alt1.gmail-smtp-in.l.google.com')]
BAD someone@example.com - The domain name example.com does not accept email.
BAD x@no-such-domain-zzzz-12345.com - The domain name no-such-domain-zzzz-12345.com does not exist.

example.com publishes a null MX (RFC 7505), so the library correctly says it doesn’t accept email.

When should you turn deliverability on? The README’s guidance is to use it for first-time validation, such as account creation, and leave it off on login forms, where it only adds DNS traffic. In tests, pass test_environment=True to skip DNS and allow @test domains.

Also note what the library doesn’t do. Its README states plainly that contacting the recipient’s SMTP server isn’t worth it, so it never does. A passing deliverability check means “this domain is set up to receive mail”, not “this mailbox exists”.

Validating email with a regex in Python

If you can’t add a dependency, a regex is the fallback. This pattern, explained in detail in our email validation regex guide, enforces the 64-character local-part limit, the 254-character total, no leading, trailing, or doubled dots, and a dotted domain:

import re

EMAIL_RE = re.compile(
    r"^(?=.{1,254}$)(?=[^@]{1,64}@)(?!\.)(?!.*\.\.)"
    r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+(?<!\.)@"
    r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+"
    r"[A-Za-z](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$"
)

def is_email_syntax(value: str) -> bool:
    return EMAIL_RE.fullmatch(value.strip()) is not None

assert is_email_syntax("first.last+tag@sub.example.co.uk")
assert not is_email_syntax("jane..doe@example.com")
assert not is_email_syntax("jane@localhost")

Use fullmatch(), not match(). Python’s $ also matches right before a trailing newline, so EMAIL_RE.match("user@example.com\n") succeeds while fullmatch correctly fails. This pattern is ASCII-only; it rejects internationalized addresses that email-validator accepts.

What about the standard library?

People often reach for email.utils.parseaddr(). It’s a header parser, not a validator: its job is to split a header value like "Jane Doe" <jane@example.com> into a display name and an address. The Python docs note that Python 3.13 added a strict parameter, on by default, that rejects malformed input; older versions parse more loosely. Even in strict mode it accepts things you probably don’t want in a signup form, such as a bare jane with no @. Use it to extract addresses from headers, then validate what it returns.

from email.utils import parseaddr

name, addr = parseaddr('"Jane Doe" <jane@example.com>')
print(name, addr)  # Jane Doe jane@example.com
# Now validate addr with email-validator or the regex above.

Doing the MX lookup yourself with dnspython

If you want full control over DNS (for example, to log the MX hosts or apply your own timeout policy), use dnspython directly. It’s already installed as a dependency of email-validator; otherwise:

python -m pip install dnspython
import dns.name
import dns.resolver

resolver = dns.resolver.Resolver()
resolver.lifetime = 5.0  # total seconds allowed per query

def mail_hosts(domain: str) -> list[str] | None:
    """MX hosts by preference; [] if the domain takes no mail; None if it doesn't exist."""
    try:
        answer = resolver.resolve(domain, "MX")
    except dns.resolver.NXDOMAIN:
        return None
    except dns.resolver.NoAnswer:
        # No MX: RFC 5321 section 5.1 treats the domain's own A/AAAA as an implicit MX.
        for rdtype in ("A", "AAAA"):
            try:
                resolver.resolve(domain, rdtype)
                return [domain]
            except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
                continue
        return []

    records = sorted(answer, key=lambda r: r.preference)
    if len(records) == 1 and records[0].exchange == dns.name.root:
        return []  # RFC 7505 null MX: "0 ."
    return [r.exchange.to_text(omit_final_dot=True) for r in records]

for d in ["gmail.com", "example.com", "github.io", "no-such-domain-zzzz-12345.com"]:
    print(d, mail_hosts(d))
gmail.com ['gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com', ...]
example.com []
github.io ['github.io']
no-such-domain-zzzz-12345.com None

dnspython raises NXDOMAIN when the name doesn’t exist and NoAnswer when it exists but has no records of that type. It can also raise LifetimeTimeout and NoNameservers. The function above lets those propagate on purpose: a DNS timeout means “unknown”, and you shouldn’t reject a user’s address because your resolver had a bad moment. Catch them at the call site and treat the address as unverified.

For how MX records drive mail routing in general, see what an MX record is.

Putting it together in a Flask or Django form

Whatever framework you use, the order is the same:

  1. Strip whitespace and validate syntax (email-validator with check_deliverability=False, or the regex).
  2. On signup, run the deliverability check with a short timeout. Treat DNS errors as “accept but flag”.
  3. Store info.normalized.
  4. Send a confirmation email. That’s the only step that proves the person controls the inbox, and it’s the backbone of double opt-in.

Django’s built-in EmailValidator and Pydantic’s EmailStr both cover step 1 in their own ways (Pydantic’s EmailStr uses email-validator under the hood and requires it to be installed). Neither does step 2 or 4 for you.

Calling a verification API from Python

Syntax and MX checks leave gaps: disposable inbox services have perfectly valid MX records, gmial.com typos need a correction table, and role addresses like info@ may or may not be welcome. The API on this site bundles those checks. Here’s a standard-library client:

import json
import os
import urllib.parse
import urllib.request

BASE = "https://bookmyspot.us/tools/email-verifier/api/v1"
API_KEY = os.environ["VERIFIER_API_KEY"]

def verify(email: str) -> dict:
    url = f"{BASE}/verify?{urllib.parse.urlencode({'email': email})}"
    req = urllib.request.Request(url, headers={"X-API-Key": API_KEY})
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.load(resp)

result = verify("jane@gmial.com")
print(result["status"], result["reason"], result["suggestion"])
# status is "valid", "risky", or "invalid"; suggestion is a typo fix or None

urlopen raises urllib.error.HTTPError for non-2xx responses, such as a 429 when you hit the daily limit. The response also carries checks.syntax, checks.mx, checks.disposable, checks.role, checks.free_provider, and the mx host list. It doesn’t open SMTP connections to test mailboxes. Get a free key on the API documentation page, see the email verification API guide for bulk requests and status handling, or check a single address with the free email verifier.

FAQ

What is the best Python library to validate email addresses?

email-validator is the most widely used dedicated option. It validates syntax against the RFCs, supports internationalized addresses, returns a normalized form for storage, and can optionally check DNS. Django and Pydantic each provide their own email field validation as well.

Does email-validator check whether the mailbox exists?

No. Its optional deliverability check confirms that the domain has MX records (or an acceptable fallback) and doesn’t publish a null MX. It intentionally never contacts the mail server, so a passing result can still bounce if the mailbox doesn’t exist.

Why does my regex accept user@gmial.con?

Because it’s syntactically valid. .con is not a real top-level domain, but a regex only checks the shape of the string. A DNS check would reject it, since the domain doesn’t exist, and a typo list could suggest gmail.com.

Should I lowercase email addresses before storing them?

Lowercasing the domain is always safe. The local part is case-sensitive under RFC 5321, though most providers ignore case in practice. A common approach is to store the address as normalized by email-validator and compare case-insensitively when checking for duplicates.

Check your list before you send

Free email verifier: syntax, MX, disposable and role-based addresses, typos. Paste up to 100 at once.

Verify emails free