Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / June 17, 2026

How to validate an email address in Python

5 minutes read

Qualisend team
How to validate an email address in Python

Validating an email address in Python means three separate checks, and the one most tutorials show — a regex — is the least useful of them. Real validation is layered: syntax, then a DNS lookup, then an SMTP mailbox probe. Python's standard library and one small dependency cover the first two; the third is a network problem worth delegating. This guide builds each layer with working code.

The short answer#

Use a permissive regex (or email.utils.parseaddr) for syntax, dnspython for the MX lookup, and a verification API for the SMTP mailbox check — cheapest first, short-circuiting as soon as one is decisive. Don't reach for smtplib to probe mailboxes from your app: outbound port 25 is blocked on most hosts, and the result depends on sending-IP reputation and greylisting you don't want to reimplement.

Layer 1: syntax#

Keep it permissive — catch typos, don't reimplement RFC 5322 (which wouldn't help anyway):

import re

SYNTAX = re.compile(r'^[^\s@."]+(?:\.[^\s@."]+)*@[^\s@.]+(?:\.[^\s@.]+)+$')

def is_valid_syntax(email: str) -> bool:
    return isinstance(email, str) and len(email) <= 320 and SYNTAX.match(email) is not None
is_valid_syntax("jane@example.com")  # True
is_valid_syntax("not-an-email")      # False
is_valid_syntax("a@@b.com")          # False
is_valid_syntax("a..b@x.com")        # False

The standard library's email.utils.parseaddr will parse an address, but it is lenient by design — parseaddr("nonsense") returns ('', 'nonsense') without raising — so it's a parser, not a validator. A short regex is clearer for a yes/no syntax gate.

Layer 2: can the domain receive mail?#

A domain with no MX records can't accept mail for anyone. dnspython resolves them in a couple of lines:

import dns.resolver  # pip install dnspython

def has_mail_route(domain: str) -> bool:
    try:
        answers = dns.resolver.resolve(domain, "MX")
        return len(answers) > 0
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
        return False
has_mail_route("gmail.com")              # True
has_mail_route("company-that-folded.com")  # False

This one lookup eliminates dead domains and made-up TLDs for free. If you want to honour domains that accept mail on an A record with no MX, fall back to resolving A/AAAA when the MX set is empty — but an MX check covers the vast majority of real addresses.

Layer 3: does the mailbox actually exist?#

Layers 1 and 2 can only rule an address out. Confirming a mailbox means the SMTP delivery conversation — and while smtplib can open one, running it from your application is a bad idea: port 25 is widely blocked, the answer depends on the reputation of the IP you connect from, and receiving servers greylist and rate-limit strangers. Delegate this layer.

Qualisend's POST /verify runs the full pipeline. The local checks return immediately with the SMTP probe queued:

import os
import requests  # pip install requests

BASE = "https://app.qualisend.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['QUALISEND_API_KEY']}"}

def verify(email: str) -> dict:
    res = requests.post(f"{BASE}/verify", headers=HEADERS, json={"email": email}, timeout=10)
    res.raise_for_status()
    return res.json()

For live signup validation the immediate result is usually enough to act on. When you need the SMTP-confirmed verdict, check result["smtp"] first: cached or skipped means the result is already final, and only pending needs a poll of the job. Run that poll from a background task, not inside the request:

import time

def verify_and_wait(email: str, tries: int = 10, delay: float = 1.5):
    data = verify(email)
    if data["result"]["smtp"] != "pending":
        return data["result"]  # cached or skipped: already final
    job_id = data["job_id"]
    for _ in range(tries):
        res = requests.get(f"{BASE}/jobs/{job_id}",
                           params={"include": "results"}, headers=HEADERS, timeout=10)
        job = res.json()
        if job["status"] == "completed":
            return job["results"][0]  # {"status": ..., "score": ..., "reason": ...}
        if job["status"] in ("canceled", "failed"):
            return None
        time.sleep(delay)
    return None  # still processing — treat as unknown, retry later

Verifying a list in bulk#

Don't loop verify() over a CSV one address at a time — that's one HTTP round-trip and one job per row. Post the whole list to /verify/bulk in a single request, get back one job_id, and poll once for the entire batch. Duplicate rows are dropped (and refunded) and results come back in pages of up to 1,000, so page through them and key each row by its email:

def verify_bulk(emails: list[str], tries: int = 60, delay: float = 2.0):
    res = requests.post(f"{BASE}/verify/bulk", headers=HEADERS,
                        json={"text": "\n".join(emails), "expected_count": len(emails)},
                        timeout=30)
    res.raise_for_status()
    job_id = res.json()["job_id"]

    for _ in range(tries):
        job = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=30).json()
        if job["status"] == "completed":
            return fetch_results(job_id)
        if job["status"] in ("canceled", "failed"):
            return None
        time.sleep(delay)  # back off; large lists take longer than a single probe
    return None

def fetch_results(job_id: str, page_size: int = 1000) -> dict:
    results, offset = {}, 0
    while True:
        page = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=30,
                            params={"include": "results", "limit": page_size,
                                    "offset": offset}).json()["results"]
        results.update({row["email"]: row for row in page})
        if len(page) < page_size:
            return results
        offset += page_size

A batch of thousands can take minutes because each SMTP probe is a separate network conversation, so widen tries/delay for bigger lists rather than hammering the poll endpoint. Run the two local layers first to drop obvious junk before you spend credits — filter with is_valid_syntax and has_mail_route, then send only the survivors.

Async and pydantic notes#

requests is blocking, so in an asyncio app wrap the call with asyncio.to_thread(verify, email) or switch to httpx.AsyncClient to fire the verify calls concurrently; dnspython ships an async resolver (dns.asyncresolver) for the same reason. If you already use pydantic, its EmailStr type is a clean drop-in for Layer 1 — it validates syntax when a model is constructed — but it stops there: it never touches DNS or the mailbox, so keep the MX check and the verification API downstream of it.

Putting the layers together#

Cheapest first, stop as soon as you have an answer:

def validate_email(email: str) -> dict:
    if not is_valid_syntax(email):
        return {"status": "undeliverable", "reason": "invalid_email"}
    domain = email.rsplit("@", 1)[1]
    if not has_mail_route(domain):
        return {"status": "undeliverable", "reason": "invalid_domain"}
    try:
        return verify(email)["result"]  # deliverable | risky | undeliverable | unknown
    except requests.RequestException:  # timeout, 402, 429, 5xx: fail open
        return {"status": "unknown", "reason": None}  # let it through, re-verify later

If the API call itself fails (a timeout, 402 out of credits, 429 rate limited or a 5xx), the function fails open with unknown: let the signup through and re-verify the address later.

The two local layers cost nothing and catch most junk instantly; the API runs only on addresses worth the round-trip. See how email verification works for why the stages sit in this order.

Frequently asked questions

01Can I verify an email in Python with just the standard library?

Up to a point. re handles syntax and, with socket, you can attempt DNS - but there's no built-in MX resolver (hence dnspython), and confirming a mailbox needs an SMTP probe you shouldn't run from your app. The standard library gets you syntax and a rough domain check; the mailbox layer needs dnspython plus a verification service.

02Why not use smtplib to check if a mailbox exists?

smtplib can open the SMTP conversation, but running it from your application server is unreliable and risky: most hosts block outbound port 25, the response depends on your sending IP's reputation, and probing at any volume gets you rate-limited or blocklisted. A verification API runs the probe from reputation-managed infrastructure built for it.

03Is dnspython the right library for MX lookups?

Yes - it's the standard, well-maintained DNS toolkit for Python and resolves MX records cleanly. Install it with pip install dnspython and catch NXDOMAIN, NoAnswer, and NoNameservers to treat a missing mail route as undeliverable.

04Does pydantic's EmailStr fully validate an email address?

No - EmailStr (backed by the email-validator package) only checks that a string is syntactically a valid address when your model is constructed. It doesn't confirm the domain has MX records or that the mailbox exists, so it maps exactly to Layer 1. Use it as the syntax gate in your pydantic models, then run the DNS lookup and the verification API on the addresses that pass. It also normalizes the address (lowercases the domain), which is handy before you dedupe a list.


Ready to add the SMTP layer? The free plan includes 100 credits that run the complete pipeline, and the API reference has the full /verify and /jobs endpoints with copy-paste examples in seven languages.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started