Email verification in Python
Python can validate an email address at four depths. A regex checks the shape. The email-validator package checks the real syntax rules and whether the domain takes mail. An SMTP handshake asks the mail server about the mailbox itself, and the ZapBounce API runs that handshake for you and returns a verdict.
The first two layers run locally and cost nothing, so every signup form should have them. The third is where a script on your own machine stops being dependable. The fourth is one POST with requests, and there's no SDK to keep current.
Whichever layer you stop at, use a requests Session for the API calls. It pools the TCP connection, so a loop of ten thousand single checks reuses one connection instead of opening ten thousand.
requests.Session for pooling, urllib3.Retry mounted on the adapter so 429 and 5xx back off without a hand-rolled loop.
What Python can check before any API call
Start with what's free. A regex catches a missing @ or a stray space in a form field. The email-validator package goes further: it applies the actual address rules, normalizes the domain, and with deliverability checks on it confirms in DNS that the domain accepts mail.
None of that proves a mailbox exists. For that you need the SMTP handshake: connect to the domain's mail server on port 25, send RCPT TO for the address, read the reply, and quit before DATA so nothing is delivered. smtplib does it in a dozen lines, and it's worth running once to watch the conversation.
At volume, that script meets three walls. Port 25 is blocked by most residential ISPs and many cloud providers, so the connection often never opens. Gmail and Microsoft 365 block probes from cloud IPs. Catch-all domains answer 250 to every name, so a yes means nothing until you also probe a random address. ZapBounce runs the handshake from its own check and returns one of four results, which is where the code below picks up.
import re
import smtplib
import dns.resolver # pip install dnspython
from email_validator import validate_email, EmailNotValidError # pip install email-validator
# 1. Shape only. Fine for catching typos in a form field.
SHAPE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+quot;)
# 2. Real syntax rules plus a DNS check that the domain accepts mail.
def check_syntax(email: str) -> str | None:
try:
info = validate_email(email, check_deliverability=True)
return info.normalized
except EmailNotValidError as exc:
print(f"rejected: {exc}")
return None
# 3. The SMTP handshake by hand. Quits before DATA, so nothing is sent.
def smtp_probe(email: str) -> int:
domain = email.rsplit("@", 1)[1]
mx = min(dns.resolver.resolve(domain, "MX"), key=lambda r: r.preference)
with smtplib.SMTP(str(mx.exchange).rstrip("."), 25, timeout=10) as smtp:
smtp.ehlo("verify.example.com")
smtp.mail("probe@verify.example.com")
code, _ = smtp.rcpt(email) # 250 accepted, 550 no such user, 4xx try later
return codeInstall
pip install requestsVerify one address
You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
import os
import requests
session = requests.Session()
session.headers.update({"X-API-Key": os.environ["ZAPBOUNCE_KEY"]})
def verify(email: str) -> dict:
r = session.post(
"https://app.zapbounce.com/api/v1/verify",
json={"emails": [email]},
timeout=(3.05, 10), # connect, read — the read side is the slow mail server
)
r.raise_for_status()
return r.json()["results"][0]
result = verify("ada@example.com")
if result["status"] == "valid":
send_to(result["email"])
elif result["status"] == "catch_all":
queue_for_separate_send(result["email"]) # the domain accepts everything
elif result["status"] == "unknown":
keep_and_retry_later(result["email"]) # credit refunded, not a verdict
else:
suppress(result["email"])
if "disposable" in result["flags"]:
suppress(result["email"])Verify a list
Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
import time
from typing import Iterator
API = "https://app.zapbounce.com/api/v1"
DONE = {"completed", "partial", "failed", "cancelled"}
def submit(emails: list[str]) -> str:
r = session.post(
f"{API}/jobs",
json={"emails": emails, "source": "api"}, # up to 50,000 per job
timeout=(3.05, 30),
)
r.raise_for_status()
return r.json()["id"]
def wait(job_id: str, every: float = 5.0) -> dict:
"""Poll the job until it reaches a terminal status. There is no webhook."""
while True:
job = session.get(f"{API}/jobs/{job_id}", timeout=(3.05, 30)).json()
if job["status"] in DONE:
return job
time.sleep(every)
def rows(job_id: str) -> Iterator[dict]:
"""Yield every row, page by page. Never build the full list in memory."""
page = 1
while True:
body = session.get(
f"{API}/jobs/{job_id}/emails",
params={"page": page, "pageSize": 100}, timeout=(3.05, 30),
).json()
yield from body["items"]
if page >= body["meta"]["totalPages"]:
return
page += 1
job_id = submit(load_emails("contacts.csv"))
job = wait(job_id)
print(f"{job['validEmails']} valid, {job['invalidEmails']} invalid, "
f"{job['catchAllEmails']} catch-all, {job['otherEmails']} unknown (refunded)")
for row in rows(job_id):
handle(row)Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.
import random
import time
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# The API sends no Retry-After header, so urllib3 only retries 5xx here.
# 429 is handled below, using X-RateLimit-Window-Seconds.
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET", "POST"],
)
session.mount("https://", HTTPAdapter(max_retries=retry, pool_maxsize=20))
def verify_with_backoff(email: str, attempts: int = 4) -> dict:
for attempt in range(attempts):
try:
return verify(email)
except requests.HTTPError as exc:
resp = exc.response
body = resp.json() # {"statusCode", "code", "message", "requestId"}
if body.get("code") == "CREDITS_INSUFFICIENT":
raise OutOfCredits(body["message"]) from exc # 422, retrying will not help
if resp.status_code == 429 and attempt < attempts - 1:
window = float(resp.headers.get("X-RateLimit-Window-Seconds", 60))
time.sleep(window * (2 ** attempt) / 4 + random.random())
continue
logger.warning("verify failed request_id=%s", body.get("requestId"))
raise
except requests.Timeout:
# A verifier timing out is not a reason to block a signup. Fail open.
return {"email": email, "status": "unknown", "reasonCode": "TIMEOUT", "flags": []}
raise RuntimeError("rate limited")Python: common questions
How do I validate an email address in Python?
Check the shape with a regex or the email-validator package, then ask the mail server with an SMTP handshake or one POST to a verification API. The local checks catch typos and dead domains for free. Only the handshake tells you whether the mailbox exists.
Is a regex enough to validate an email in Python?
A regex is enough to catch typos, and nothing more. An address like nobody@a-real-domain.com passes every pattern and still bounces if the mailbox was never created. Use the regex as a filter, then verify.
Can Python check if an email exists without sending one?
Yes, with smtplib: connect on port 25, send RCPT TO, read the reply and quit before DATA. A 250 means accepted and a 550 means no such user. Most residential ISPs and many cloud hosts block that port, which is why the API runs that step for you.
Do I need a Python SDK?
No. The API is a handful of HTTP calls, so requests plus the retry adapter above is the whole integration, with no extra dependency to keep current.
How do I verify at signup without blocking the form?
Give the call a two-second read timeout and treat a timeout as a pass. A slow mail server on the other end should never cost you a registration.
Run this Python code today
100 free checks a month, no card, credits that never expire, and unknown results refunded.