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

How to validate an email address in Python

4 minutes read

Qualisend team
A Python code window validating an email through syntax, DNS, and SMTP layers

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

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, poll the job until the probe finishes:

import time

def verify_and_wait(email: str, tries: int = 10, delay: float = 1.5):
    job_id = verify(email)["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": ...}
        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 in a single request, get back one job_id, and poll once for the entire batch. The results array comes back in submission order:

def verify_bulk(emails: list[str], tries: int = 60, delay: float = 2.0):
    res = requests.post(f"{BASE}/verify", headers=HEADERS,
                        json={"emails": 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}",
                          params={"include": "results"}, headers=HEADERS,
                          timeout=30).json()
        if job["status"] == "completed":
            return dict(zip(emails, job["results"]))
        time.sleep(delay)  # back off; large lists take longer than a single probe
    return None

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"}
    return verify(email)["result"]  # deliverable | risky | undeliverable | unknown

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#

Can 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.

Why 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.

Is 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.

Does 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 normalises 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