Verify an email address in Python: single checks, batches and CSV files
· 4 min read · Tutorials
To verify an email address in Python, send it to a verification API with requests and read back a status: valid, invalid, risky or unknown. The code below does that for one address, for batches of up to 50, and for a whole CSV file, with retries on HTTP 429 so a long run doesn't fall over halfway. It uses pingutil's /v1/verify endpoints; the structure carries over to any HTTP verification API.
You need Python 3.9 or newer and requests:
pip install requests
export PINGUTIL_KEY=pk_your_key_here
Verify one address
# verify_one.py
import os
import sys
import requests
API = "https://pingutil.com/v1/verify"
KEY = os.environ["PINGUTIL_KEY"]
def verify(email: str) -> dict:
r = requests.post(
API,
json={"email": email},
headers={"Authorization": f"Bearer {KEY}"},
timeout=60,
)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
result = verify(sys.argv[1])
print(result["status"], result["reason"], result["score"])
if result["did_you_mean"]:
print("did you mean:", result["did_you_mean"])
python verify_one.py jane@gmial.com
The response is an EmailVerification object. The fields you'll use most are status, reason, score (0 to 100) and did_you_mean (a corrected address when the domain looks like a typo of a big provider). The checks object has the individual facts behind the decision: syntax, disposable, free_provider, role_based, randomized, domain_exists, has_mx, mailbox and catch_all. What each status and reason means is in email verification statuses explained.
The timeout is generous on purpose. A mailbox check talks to the recipient's mail server, and slow servers take a while to answer.
Batches, retries and CSV in one script
For lists, use POST /v1/verify/batch with up to 50 addresses per call. Results come back in {"results": [...]} in the same order you sent them, which makes it easy to zip them back onto your rows.
Two kinds of 429 need different handling:
rate_limited: you sent more than 60 requests in a minute. Wait and retry. RFC 6585 says a 429 response may include aRetry-Afterheader; pingutil puts the wait in the JSON body asretry_after_seconds. The script checks both and falls back to exponential backoff.quota_exceeded: the account's daily verification budget (300 addresses, reset at 00:00 UTC) is used up. Retrying won't help, so the script stops and writes what it has.
# verify_csv.py
"""Verify the email column of a CSV and write the results to a new CSV.
usage: python verify_csv.py leads.csv leads_verified.csv [email_column]
"""
import csv
import os
import sys
import time
import requests
BATCH_URL = "https://pingutil.com/v1/verify/batch"
KEY = os.environ["PINGUTIL_KEY"]
BATCH_SIZE = 50
MAX_RETRIES = 5
RESULT_COLUMNS = ["status", "reason", "score", "did_you_mean", "mx_host"]
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {KEY}"})
class QuotaExceeded(Exception):
pass
def wait_seconds(resp: requests.Response, attempt: int) -> float:
header = resp.headers.get("Retry-After")
if header and header.isdigit():
return float(header)
try:
body = resp.json()
if body.get("retry_after_seconds"):
return float(body["retry_after_seconds"])
except ValueError:
pass
return min(2 ** attempt, 60)
def verify_batch(emails: list[str]) -> list[dict]:
for attempt in range(MAX_RETRIES):
resp = session.post(BATCH_URL, json={"emails": emails}, timeout=120)
if resp.status_code == 200:
return resp.json()["results"]
if resp.status_code == 401:
sys.exit("401 unauthorized: check PINGUTIL_KEY")
if resp.status_code == 429:
error = resp.json().get("error")
if error == "quota_exceeded":
raise QuotaExceeded(resp.json().get("message", "daily quota reached"))
delay = wait_seconds(resp, attempt)
print(f"rate limited, waiting {delay:.0f}s", file=sys.stderr)
time.sleep(delay)
continue
if resp.status_code >= 500:
time.sleep(min(2 ** attempt, 60))
continue
resp.raise_for_status()
raise RuntimeError(f"gave up after {MAX_RETRIES} attempts")
def main() -> None:
src, dst = sys.argv[1], sys.argv[2]
column = sys.argv[3] if len(sys.argv) > 3 else "email"
with open(src, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
fieldnames = list(reader.fieldnames or [])
rows = list(reader)
if column not in fieldnames:
sys.exit(f"column {column!r} not found; columns are {fieldnames}")
# Only send rows that have something in the email column.
todo = [i for i, row in enumerate(rows) if (row.get(column) or "").strip()]
done = 0
try:
for start in range(0, len(todo), BATCH_SIZE):
chunk = todo[start : start + BATCH_SIZE]
results = verify_batch([rows[i][column].strip() for i in chunk])
for i, result in zip(chunk, results):
for key in RESULT_COLUMNS:
value = result.get(key)
rows[i][key] = "" if value is None else value
done += len(chunk)
print(f"{done}/{len(todo)} verified", file=sys.stderr)
except QuotaExceeded as e:
print(f"stopping early: {e}", file=sys.stderr)
with open(dst, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames + RESULT_COLUMNS)
writer.writeheader()
writer.writerows(rows)
print(f"wrote {dst}: {done} of {len(todo)} addresses verified", file=sys.stderr)
if __name__ == "__main__":
main()
Run it:
python verify_csv.py leads.csv leads_verified.csv email
A few details worth knowing:
encoding="utf-8-sig"strips the byte-order mark that Excel adds to CSV exports, so the first column name isn'temail. The standard library'scsvmodule docs recommend opening files withnewline="", which the script does.- Rows that weren't verified (blank emails, or anything after a quota stop) keep empty result columns. Re-run on the unverified rows the next day.
- Results for the same address are cached on pingutil's side for 30 days (one hour for greylisted or blocked answers), and each result has a
cachedflag. Cached answers still count toward the daily budget, so dedupe your file before you send it.
Deciding what to keep
Once you have the output file, a simple filter is enough for most lists:
keep = [r for r in rows if r.get("status") == "valid"]
review = [r for r in rows if r.get("status") in ("risky", "unknown")]
drop = [r for r in rows if r.get("status") == "invalid"]
Look at review by reason. accept_all (catch-all domains) and unknown deserve a second look rather than deletion, which catch-all email domains explains. For an older, domain-only version of this script, see cleaning an email list from a CSV.
Field details are in the API reference, and you can test single addresses in the verification demo before you run a file. Prefer JavaScript? The Node.js version has the same structure.
Questions people ask
- Can I verify an email address in Python without an API?
- You can check syntax and look up MX records with libraries like dnspython, and you can open an SMTP connection with smtplib. The mailbox check needs outbound port 25, which many cloud providers block, and it has to handle catch-all domains and greylisting, so most teams use an API for that part.
- How do I handle rate limits when verifying a large list in Python?
- Send addresses in batches, and when the API returns HTTP 429, wait before retrying. Use the Retry-After header or the retry_after_seconds field if present, and fall back to exponential backoff. Stop instead of retrying when the error says a daily quota is used up.