Python Tutorial

Python Email Validation:
Verify & Clean Email Lists with requests

This tutorial shows how to verify and clean email lists in Python using the ip-api.io batch API. Unlike syntax checkers or DNS-only validators, the API performs a live SMTP handshake with each recipient's mail server to confirm the inbox exists — detecting invalid, disposable, catch-all, and role-based addresses before they damage your sender reputation.

What you'll build

  • Verify a single email address via live SMTP
  • Batch verify up to 100 emails per request and categorize into clean / risky / invalid
  • Upload a CSV file and stream results back as clean / full report
  • Production-ready helper class with error handling, rate limit detection, and async variant

Prerequisites

  • Python 3.8 or higher
  • The requests library (installed below)
  • An ip-api.io API key — get one free
1

Install requests

Install the requests library if you don't have it already. Pin the version in requirements.txt for reproducible installs.

pip install requests
# requirements.txt
requests>=2.28
2

Quickstart: verify a single email

The batch endpoint accepts an array of up to 100 addresses — pass a single-element list for individual checks. The API performs a live SMTP handshake and returns the result in one response. Store your API key in an environment variable — never hardcode it.

import os
import requests

API_KEY = os.environ["IP_API_IO_KEY"]

resp = requests.post(
    "https://ip-api.io/api/v1/email/advanced/batch",
    headers={"X-Api-Key": API_KEY},
    json={"emails": ["user@example.com"]},
    timeout=30,
)
resp.raise_for_status()

results = resp.json()
data = results["user@example.com"]

print(data["reachable"])           # "yes", "no", or "unknown"
print(data["disposable"])          # False
print(data["role_account"])        # False
print(data["smtp"]["catch_all"])   # False
print(data["has_mx_records"])      # True
print(data["suggestion"])          # None, or e.g. "user@gmail.com" for typos
API key required for bulk use: Anonymous requests are rate-limited per IP. Add an X-Api-Key header with your API key to verify larger lists — subscribe at ip-api.io/pricing to get started.
3

Batch verify a list

For lists larger than 100 addresses, split into chunks of 100 and collect all results before categorizing. The API processes each batch concurrently — a full 100-address batch typically completes in 15–60 seconds depending on SMTP server response times.

import os
import csv
import requests
from itertools import islice

API_KEY = os.environ["IP_API_IO_KEY"]


def _chunks(iterable, size):
    it = iter(iterable)
    while chunk := list(islice(it, size)):
        yield chunk


def verify_emails(emails: list[str]) -> dict:
    """Verify any number of emails, 100 per API request."""
    all_results = {}
    for chunk in _chunks(emails, 100):
        resp = requests.post(
            "https://ip-api.io/api/v1/email/advanced/batch",
            headers={"X-Api-Key": API_KEY},
            json={"emails": chunk},
            timeout=90,
        )
        resp.raise_for_status()
        all_results.update(resp.json())
    return all_results


def categorize(data: dict) -> str:
    if data["reachable"] == "yes" and not data["disposable"]:
        return "clean"
    if data["reachable"] == "no" or data["disposable"]:
        return "invalid"
    return "risky"


emails = ["alice@example.com", "test@mailinator.com", "bob@company.org"]
results = verify_emails(emails)

clean   = [e for e, d in results.items() if categorize(d) == "clean"]
risky   = [e for e, d in results.items() if categorize(d) == "risky"]
invalid = [e for e, d in results.items() if categorize(d) == "invalid"]

print(f"Clean: {len(clean)}, Risky: {len(risky)}, Invalid: {len(invalid)}")

# Write the clean list to a CSV file
with open("clean_list.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["email"])
    writer.writerows([[e] for e in clean])
Tip: Quota is only deducted on successful completion — a failed batch or HTTP error costs nothing. Retry failed chunks with exponential backoff before reporting an error.
4

Upload a CSV file

The CSV endpoint accepts a multipart/form-data POST with a CSV or plain-text file up to 1MB. The API reads the first column as the email address and auto-detects header rows. Use requests' files= parameter — no need to set Content-Type manually.

import os
import requests

API_KEY = os.environ["IP_API_IO_KEY"]


def verify_csv(path: str) -> dict:
    """Upload a CSV file for batch verification.

    First column used as email; header row auto-detected; max 1MB per request.
    For files larger than 1MB, split and send multiple requests.
    """
    with open(path, "rb") as f:
        resp = requests.post(
            "https://ip-api.io/api/v1/email/advanced/batch/csv",
            headers={"X-Api-Key": API_KEY},
            files={"file": f},
            timeout=90,
        )
    resp.raise_for_status()
    return resp.json()


results = verify_csv("email_list.csv")
print(f"Verified {len(results)} addresses")

# Extract clean addresses
clean = [
    email for email, data in results.items()
    if data["reachable"] == "yes" and not data["disposable"]
]
print(f"{len(clean)} deliverable addresses")
CSV format: The first column is used as the email address. Other columns are ignored. Header rows are detected automatically — if the first cell contains no @ sign it is treated as a header and skipped. Comma, semicolon, and tab delimiters are all accepted.
5

Production-ready helper

A complete EmailListCleaner class with error handling for timeouts, rate limits, and HTTP errors, plus a method to clean a CSV file in one call.

import os
import csv
import requests
from itertools import islice

API_KEY = os.environ["IP_API_IO_KEY"]
_BATCH_URL = "https://ip-api.io/api/v1/email/advanced/batch"
_CSV_URL   = "https://ip-api.io/api/v1/email/advanced/batch/csv"


def _chunks(iterable, size):
    it = iter(iterable)
    while chunk := list(islice(it, size)):
        yield chunk


class EmailListCleaner:
    """Batch email verifier using the ip-api.io SMTP verification API."""

    def __init__(self, api_key: str = API_KEY, timeout: int = 90):
        self.session = requests.Session()
        self.session.headers["X-Api-Key"] = api_key
        self.timeout = timeout

    def verify_batch(self, emails: list[str]) -> dict:
        """Verify up to 100 emails in one API request.

        Returns a dict keyed by email address.
        Raises RuntimeError on HTTP errors or rate limiting.
        """
        try:
            resp = self.session.post(
                _BATCH_URL,
                json={"emails": emails},
                timeout=self.timeout,
            )
            if resp.status_code == 429:
                raise RuntimeError(
                    "Rate limit reached — upgrade your plan or add an API key"
                )
            resp.raise_for_status()
            return resp.json()
        except requests.Timeout:
            raise RuntimeError(
                "Request timed out (SMTP verification can take up to 60s for 100 addresses)"
            )
        except requests.HTTPError as exc:
            raise RuntimeError(f"API error HTTP {exc.response.status_code}") from exc

    def clean_list(
        self, emails: list[str]
    ) -> tuple[list[str], list[str], list[str]]:
        """Verify a list of any size. Returns (clean, risky, invalid)."""
        clean, risky, invalid = [], [], []
        for chunk in _chunks(emails, 100):
            results = self.verify_batch(chunk)
            for email, data in results.items():
                if data["reachable"] == "yes" and not data["disposable"]:
                    clean.append(email)
                elif data["reachable"] == "no" or data["disposable"]:
                    invalid.append(email)
                else:
                    risky.append(email)
        return clean, risky, invalid

    def clean_csv(self, input_path: str, output_path: str) -> int:
        """Read input_path CSV, verify all addresses, write clean ones to output_path.

        Returns the number of deliverable addresses written.
        """
        with open(input_path, newline="") as f:
            rows = list(csv.reader(f))

        start = 1 if rows and "@" not in rows[0][0] else 0
        emails = [row[0].strip() for row in rows[start:] if row]

        clean, _, _ = self.clean_list(emails)

        with open(output_path, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["email"])
            writer.writerows([[e] for e in clean])

        return len(clean)


# Usage
cleaner = EmailListCleaner()
n = cleaner.clean_csv("raw_list.csv", "clean_list.csv")
print(f"{n} deliverable addresses written to clean_list.csv")

Prefer async Python? Use httpx as a drop-in replacement:

import httpx
import os

API_KEY = os.environ["IP_API_IO_KEY"]


async def verify_emails_async(emails: list[str]) -> dict:
    async with httpx.AsyncClient(
        headers={"X-Api-Key": API_KEY},
        timeout=90,
    ) as client:
        resp = await client.post(
            "https://ip-api.io/api/v1/email/advanced/batch",
            json={"emails": emails},
        )
        resp.raise_for_status()
        return resp.json()
Try the browser tool: The Email List Cleaning page lets you paste addresses or upload a CSV and see results instantly — useful for testing your list before automating with Python.

API Response Reference

The batch endpoint returns a JSON object keyed by email address. Each value contains the full verification result including SMTP status, syntax check, and risk flags.

POST /api/v1/email/advanced/batch
{
  "alice@example.com": {
    "email": "alice@example.com",
    "reachable": "yes",
    "syntax": {
      "username": "alice",
      "domain":   "example.com",
      "valid":    true
    },
    "smtp": {
      "host_exists":  true,
      "deliverable":  true,
      "full_inbox":   false,
      "catch_all":    false,
      "disabled":     false
    },
    "gravatar": {
      "has_gravatar": false,
      "gravatar_url": null
    },
    "suggestion":   null,
    "disposable":   false,
    "role_account": false,
    "free":         false,
    "has_mx_records": true
  }
}

Top-level fields

Field Type Description
reachable string Overall verdict: "yes" (inbox confirmed), "no" (inbox rejected), "unknown" (catch-all or inconclusive)
disposable boolean Matched against 10,000+ throwaway / temporary email domains
role_account boolean Role-based address such as admin@, noreply@, support@
free boolean Address belongs to a free email provider (Gmail, Hotmail, Yahoo, etc.)
has_mx_records boolean Domain has valid MX records pointing to a mail server
suggestion string / null Corrected address for common typos — e.g. "gmial.com""gmail.com"

smtp — live SMTP check results

Field Type Description
deliverable boolean Mail server confirmed the inbox exists and will accept messages
host_exists boolean Mail server is reachable on port 25
catch_all boolean Domain accepts all addresses regardless of whether the mailbox exists — inbox cannot be individually confirmed
full_inbox boolean Mail server responded with a "mailbox full" error
disabled boolean The mailbox has been disabled or suspended

syntax — format validation

Field Type Description
valid boolean Address passes RFC 5321/5322 syntax rules
username string Local part of the address (before @)
domain string Domain part of the address (after @)

Categorization logic

Category Condition What to do
Clean reachable == "yes" and disposable == False Keep — inbox confirmed and not a throwaway address
Risky reachable == "unknown" or smtp.catch_all == True Segment separately — catch-all or inconclusive; monitor bounce rates
Invalid reachable == "no" or disposable == True Remove — inbox does not exist or is a disposable address

Role accounts (role_account == True) and free providers (free == True) are flagged but not automatically invalid — keep or filter based on your use case. For cold outreach, filtering role accounts is recommended.

Frequently asked questions

Common questions about Python email validation and the ip-api.io batch API.

How do I verify an email address in Python?

Use the requests library to POST to the ip-api.io batch endpoint:

import os, requests

API_KEY = os.environ["IP_API_IO_KEY"]

resp = requests.post(
    "https://ip-api.io/api/v1/email/advanced/batch",
    headers={"X-Api-Key": API_KEY},
    json={"emails": ["user@example.com"]},
    timeout=30,
)
data = resp.json()["user@example.com"]
print(data["reachable"])   # "yes" | "no" | "unknown"
print(data["disposable"])  # False

The reachable field reflects a live SMTP handshake with the recipient's mail server. "yes" means the inbox was confirmed; "no" means it was rejected; "unknown" means the server is a catch-all.

How do I check if an email is valid in Python without sending an email?

The ip-api.io API performs a live SMTP handshake — it connects to the recipient's mail server and asks whether the inbox exists, but it never actually delivers a message. This is more accurate than syntax checks or DNS-only validators, which only confirm the domain has MX records, not whether the specific mailbox exists.

resp = requests.post(
    "https://ip-api.io/api/v1/email/advanced/batch",
    headers={"X-Api-Key": API_KEY},
    json={"emails": ["user@example.com"]},
    timeout=30,
)
data = resp.json()["user@example.com"]

# smtp.deliverable is True only when the server confirmed the inbox
print(data["smtp"]["deliverable"])   # True
print(data["smtp"]["host_exists"])   # True  — MX server is reachable
print(data["has_mx_records"])        # True  — domain has MX records

SMTP verification accuracy is 95%+ for non-catch-all domains. The main exception is catch-all servers, which always accept connections regardless of whether the inbox exists — check smtp.catch_all to detect them.

How do I clean a Python list of emails?

Send your list in chunks of up to 100 to the batch API. Categorize results with three buckets:

def categorize(data: dict) -> str:
    if data["reachable"] == "yes" and not data["disposable"]:
        return "clean"
    if data["reachable"] == "no" or data["disposable"]:
        return "invalid"
    return "risky"

results = verify_emails(["alice@example.com", "fake@mailinator.com"])

clean   = [e for e, d in results.items() if categorize(d) == "clean"]
risky   = [e for e, d in results.items() if categorize(d) == "risky"]
invalid = [e for e, d in results.items() if categorize(d) == "invalid"]

Use itertools.islice to chunk large lists into 100-address batches. See step 3 above for the full chunking pattern. For CSV files, use the POST /api/v1/email/advanced/batch/csv endpoint (step 4).

What does reachable: "unknown" mean?

reachable: "unknown" means the mail server accepted the SMTP connection but could not confirm the specific inbox. This is almost always caused by a catch-all domain — a server configured to accept all incoming email regardless of whether the mailbox exists.

data = results["user@company.org"]

if data["reachable"] == "unknown":
    if data["smtp"]["catch_all"]:
        print("Catch-all domain — inbox cannot be confirmed individually")
    else:
        print("SMTP inconclusive — server was unresponsive or greylisting")

Catch-all addresses are common in enterprise and government domains where the address is often real. A conservative approach is to treat them as risky: keep them in a separate segment and monitor bounce rates before sending at full volume.

Pricing

Start using IP-API.io to make your website safer and more user-friendly. Keep out unwanted bots, show visitors content that's relevant to where they are, and spot risky IP addresses quickly. It's perfect for making online shopping more personal and keeping your site secure. Get started today with one of the plans!

Small

€10 /mo
100,000 geo ip requests
10,000 advanced email validation requests
Location data
Email validation
Risk score calculation
Currency data
Time zone data
Threat data
Unlimited support
HTTPS encryption

Medium

€20 /mo
300,000 geo ip requests
25,000 advanced email validation requests
Location data
Email validation
Risk score calculation
Currency data
Time zone data
Threat data
Unlimited support
HTTPS encryption
Note: Your API key will be sent to your email after the subscription is confirmed.
From our blog

Advanced Email Validation with Batch Processing: The Complete Developer's Guide

Implementation strategies for login flows, marketing list cleanup, and fraud prevention with real-time SMTP verification.

Need support?

Explore how IP-API.io can enhance your security, provide robust bot protection, and improve IP geolocation accuracy for your applications.

Contact Support

Need more queries?

Customize your experience with tailored plans that fit your IP security and geolocation needs.

Email Us