How to Verify Email Addresses in Python

Verifying an email address in Python is straightforward for the easy parts and surprisingly hard for the part that matters most. This guide walks through syntax checks and DNS lookups you can run locally, explains exactly why raw SMTP probes fail in practice, and shows how to get a mailbox-level result with a REST API.

Quick takeaway

Use Python to reject malformed input and optionally cache DNS/MX checks. Use a verification API for the mailbox-level result, because raw smtplib probes are easy to throttle and often return ambiguous answers.

What "Verifying" an Email Actually Means

Email verification is the process of confirming an address is real and able to receive mail before you send anything to it. It breaks down into a sequence of checks, each one harder than the last: is the address correctly formatted, does its domain accept mail at all, and does the specific mailbox exist. In Python the first two are easy to do well. The third is where most home-grown solutions quietly fail, and understanding why is the most useful thing in this article.

The plan below is layered. Run the cheap, fast checks locally to throw out obviously broken input, then call a verification API for the mailbox-level answer on whether the address can be used. This gives you instant rejection of garbage without an API call, and a clearer result for everything that survives.

Layered Python email verification checks using syntax validation DNS MX lookup API verification and app policy
A reliable Python implementation keeps local checks fast and cheap, then asks a managed verification API for the SMTP, catch-all, disposable, and role-based verdicts.

Step 1: Syntax Validation

The first check confirms the address is correctly formed. It is tempting to write a regular expression for this, but email syntax is far more intricate than it looks, and hand-rolled regexes are nearly always either too strict or too loose. The reference library for the job is email-validator, which implements the relevant standards properly and is widely used in production.

Install it with pip:

pip install email-validator

Then validate an address. The library raises EmailNotValidError for anything malformed and returns a normalized object for valid input:

from email_validator import validate_email, EmailNotValidError

def check_syntax(address):
    try:
        result = validate_email(address, check_deliverability=False)
        return True, result.normalized
    except EmailNotValidError as error:
        return False, str(error)

ok, value = check_syntax("jane.doe@example.com")
print(ok, value)  # True jane.doe@example.com

ok, value = check_syntax("jane.doe@@example")
print(ok, value)  # False, with a description of the problem

Setting check_deliverability=False keeps this step pure syntax with no network calls, so it is fast enough to run on every keystroke or form submission. Always store the normalized value the library returns rather than the raw user input, since it canonicalizes the address for consistent storage.

Step 2: Domain and MX Checks

A correctly formatted address is still useless if its domain cannot receive email. The next check queries the domain's DNS records and looks for MX records, which name the mail servers responsible for that domain. A domain with no MX records cannot accept mail, so any address on it can be rejected immediately.

The email-validator library can do this for you when you pass check_deliverability=True, which performs the MX lookup as part of validation:

from email_validator import validate_email, EmailNotValidError

def check_domain(address):
    try:
        result = validate_email(address, check_deliverability=True)
        return True, result.domain
    except EmailNotValidError as error:
        return False, str(error)

If you want to run the DNS query yourself, perhaps to cache results across many addresses on the same domain, use the dnspython library directly:

pip install dnspython
import dns.exception
import dns.resolver

def has_mx_record(domain):
    try:
        records = dns.resolver.resolve(domain, "MX")
        return len(records) > 0
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
        return False
    except dns.exception.DNSException:
        return False  # Treat resolver errors as inconclusive.

print(has_mx_record("gmail.com"))  # True
print(has_mx_record("no-such-domain-xyz.invalid"))  # False

This is genuinely useful. It removes addresses on domains that have expired, never existed, or simply do not handle mail. But notice what it still has not told you: whether someone@gmail.com is a real mailbox. Gmail has MX records, so every Gmail address passes this check, valid or not.

Step 3: The Mailbox Check, and Why Raw SMTP Falls Short

Confirming the mailbox itself requires talking to the receiving mail server. In principle you connect over SMTP, identify yourself, issue a RCPT TO command with the target address, and read the response code: a positive code suggests the mailbox exists, a rejection suggests it does not. Python's built-in smtplib can do this, and the code looks deceptively simple:

import smtplib
import dns.resolver

def naive_smtp_check(address):
    domain = address.split("@")[1]
    mx_host = str(dns.resolver.resolve(domain, "MX")[0].exchange)

    server = smtplib.SMTP(timeout=10)
    server.connect(mx_host)
    server.helo("example.com")
    server.mail("probe@example.com")
    code, message = server.rcpt(address)
    server.quit()

    # 250 and 251 generally mean the mailbox was accepted.
    return code in (250, 251)

This works in a tutorial and fails in production. The reasons are worth knowing in detail, because they explain why no serious verifier relies on a script like this.

IP reputation and throttling

Mailbox providers actively protect their systems from abusive or repetitive checks. A simple script can quickly receive ambiguous responses, temporary failures, or blocked requests, and those responses can make the result meaningless. Production-grade verification needs careful handling of provider behavior, retries, ambiguous responses, and uncertainty labels.

Greylisting

Many servers use greylisting, deliberately returning a temporary 4xx failure the first time an unknown sender makes contact. Legitimate mail servers retry later; spammers usually do not. A naive script that does not retry reads that temporary failure as a rejection and marks a perfectly good mailbox as invalid. That is a false negative caused purely by impatience.

Catch-all domains

A catch-all domain accepts mail to many or all addresses at the domain, so a basic mailbox check can look positive even when the specific person is not confirmed. A raw check cannot reliably tell a real mailbox from a nonexistent one on these domains. Production verification should detect and label catch-all behavior separately instead of calling those addresses simply valid.

Ambiguous responses from major providers

Some large providers have deliberately made their RCPT TO behavior ambiguous. Certain ones accept every address at the handshake stage and only bounce later, so the SMTP response simply does not carry the information you are asking for.

This is why the maintainers of email-validator deliberately leave SMTP checks out of the library. Their documentation states plainly that there is nothing to be gained from contacting an SMTP server yourself, because servers are good at not revealing whether an address is deliverable, and an address that appears to accept mail can still bounce afterward. The honest takeaway: syntax and MX checks are things you can do well in your own code, but a reliable mailbox check is not.

Step 4: Verifying with the VeriMails API

The practical solution is to let a verification service handle the mailbox check from infrastructure built for it, and to call that service from Python with a single HTTP request. VeriMails exposes a REST API that runs the full pipeline, syntax, MX, DNS, a live SMTP handshake, catch-all detection, disposable detection, and role-based detection, and returns one structured result.

Using the standard requests library, a verification call looks like this. For endpoint details, see the VeriMails API documentation:

import requests

def verify_email(address, api_key):
    response = requests.get(
        "https://api.verimails.com/v1/verify",
        params={"email": address},
        headers={"Authorization": f"Bearer {api_key}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

result = verify_email("jane.doe@example.com", api_key="YOUR_API_KEY")
print(result["status"])      # e.g. "deliverable", "undeliverable", "catch_all"
print(result["disposable"])  # True if a throwaway address
print(result["role_based"])  # True for addresses such as info@ or support@

You can then act on the result. A signup form might accept deliverable addresses, reject undeliverable and disposable ones, and decide its own policy for catch_all:

def is_acceptable_for_signup(address, api_key):
    result = verify_email(address, api_key)

    if result["disposable"]:
        return False, "Disposable addresses are not allowed."
    if result["status"] == "undeliverable":
        return False, "That address does not appear to exist."

    return True, "Address verified."

Because each call returns a clear result, it is fast enough to run inside a form submission without leaving the user waiting. For an existing list of addresses already in your database, bulk CSV verification is the better fit, since uploading one file is far more efficient than looping the single-address endpoint thousands of times.

The recommended pattern for a Python application is to combine both layers: keep the local email-validator syntax check to instantly reject garbage input before spending a credit, then call the API for the authoritative deliverability result on everything that passes. You get fast feedback for obvious mistakes and a trustworthy verdict for real addresses.

Application policy table

ResultSignup policyImport or CRM policy
deliverableAccept and store the normalized addressKeep in the active segment
undeliverableReject with a clear correction messageSuppress before any campaign
catch_allAccept or review based on account riskSegment separately and send conservatively
disposableReject for most productsSuppress from outreach lists
role_basedAllow only when shared inboxes are acceptableReview before cold outreach

API calls versus list jobs

Use the API where an immediate decision matters: signup, checkout, lead forms, invitations, and account changes. Use bulk verification for a CSV export, CRM cleanup, or campaign audience where thousands of addresses need the same review. Both workflows should feed the same suppression rules so an address rejected in one path is not mailed from another tool later.

Putting It Together

A clean implementation has three layers. First, an email-validator syntax check with check_deliverability=False, run locally and instantly to reject malformed input. Second, an optional MX check with dnspython if you want to filter dead domains before calling the API, though the API also covers this. Third, a call to the VeriMails API for the mailbox-level result, including catch-all, disposable, and role-based detection that you cannot reproduce reliably on your own.

The thing to remember is the division of labor. Syntax and DNS are genuinely yours to handle in Python. The live mailbox check depends on IP reputation, greylisting behavior, and catch-all detection that a single script cannot manage, which is why the standard validation library deliberately leaves SMTP probing out. Use local checks for speed, an API for mailbox-level verification, and the pricing page to choose the plan that fits your expected volume.

Frequently Asked Questions

You can check syntax with the email-validator library and confirm a domain has MX records with dnspython, both running entirely in your code. What you cannot reliably do alone is confirm the mailbox exists. That requires a live SMTP handshake from trusted IP addresses, and raw smtplib probes from a single server are usually blocked or throttled by mailbox providers.
Major providers detect verification probes and throttle the source IP within a few attempts, then return ambiguous responses regardless of whether the mailbox exists. Greylisting returns a temporary failure to unknown senders, which a naive script misreads as invalid. Catch-all domains accept every address. The official email-validator library deliberately omits SMTP for these reasons.
Use a layered approach: run a fast syntax check locally to reject obviously broken input, then call a verification API such as VeriMails for the mailbox-level result. The API performs the SMTP handshake from properly managed infrastructure and returns syntax, MX, DNS, SMTP, catch-all, disposable, and role-based results in one response.
The VeriMails API returns a clear result for each address, which is fast enough to call inside a form submission. New accounts get 100 free credits with no credit card, so you can test the integration before paying anything.

Start with Clean Data

100 free credits on signup. No credit card required. Put the advice into practice today.

Start Free
No credit card required. Credits never expire.