Skip to content

Block disposable emails in Python (Django and FastAPI)

· Updated · 6 min read · Disposable email

To block disposable emails in Python, install the disposable-email-domains package from PyPI, reduce each address to its registrable domain with tldextract, and reject matches in one small function. Then call that function from a Django validator or a Pydantic AfterValidator in FastAPI, so every signup and email-change path shares the same rule. The one thing to plan for is freshness: the list changes almost daily, so automate upgrades of the package.

The general detection approach is in how to detect disposable email addresses at signup. This article is the Python wiring.

The Python situation is better than in npm

On PyPI, the package disposable-email-domains is published by a maintainer of the upstream GitHub list, and it tracks that list closely. Between 6 and 12 October 2026 it shipped five releases (0.0.275 to 0.0.279). Version 0.0.279 exposes a blocklist set of 9,880 domains, the same count as the upstream file that day.

So unlike the Node ecosystem, where the obvious package hasn't been published since 2022 (see the Node.js and Express guide for the workaround), in Python you can use the package directly. The catch is pinning. Your lock file freezes the list at whatever version you installed. Add a Dependabot or Renovate rule that bumps this one package weekly and auto-merges if tests pass. If you skip that, you are running a list that decays every day; why disposable email blocklists go stale quantifies how fast.

Step 1: one shared check

pip install disposable-email-domains tldextract
# disposable.py
import os

import tldextract
from disposable_email_domains import blocklist

# Offline PSL: use the snapshot bundled with tldextract, no HTTP fetch at runtime.
_extract = tldextract.TLDExtract(suffix_list_urls=())

ALLOW = {d.strip().lower() for d in os.environ.get("DISPOSABLE_ALLOWLIST", "").split(",") if d.strip()}


def registrable_domain(email: str) -> str | None:
    _, at, host = email.strip().rpartition("@")
    if not at or not host:
        return None
    try:
        host = host.rstrip(".").encode("idna").decode("ascii").lower()
    except UnicodeError:
        return None
    return _extract(host).top_domain_under_public_suffix or None


def is_disposable(email: str) -> bool:
    domain = registrable_domain(email)
    return bool(domain) and domain in blocklist and domain not in ALLOW

What each piece is doing:

  • rpartition("@") splits on the last @, which is the one that separates local part from domain.
  • encode("idna") turns bücher.de into xn--bcher-kva.de, so internationalized domains match list entries. Python's built-in codec implements the older IDNA 2003 rules, which is fine for a lookup key; if you need strict IDNA 2008 handling elsewhere, the idna package provides it.
  • top_domain_under_public_suffix reduces mail.yopmail.fr to yopmail.fr and keeps foo.co.uk intact. The list only stores registrable domains, so without this step any subdomain walks straight past you. The reasoning is in eTLD+1 explained.
  • suffix_list_urls=() matters in production. By default, tldextract fetches the latest Public Suffix List over HTTP on first use and caches it under your home directory. In a container with a read-only filesystem or no outbound network, that is a surprise you don't want during a signup. The empty tuple makes it use the snapshot bundled with the package.
  • ALLOW is your escape hatch for list entries you disagree with, and for your own QA domains.

The function returns False for malformed input on purpose. Syntax validation is a separate job that Django's EmailField and Pydantic's EmailStr already do.

Step 2: Django

Wrap the check in a standard validator:

# accounts/validators.py
from django.core.exceptions import ValidationError

from disposable import is_disposable


def reject_disposable_email(value: str) -> None:
    if is_disposable(value):
        raise ValidationError(
            "Please use a permanent email address.",
            code="disposable_email",
        )

Attach it wherever an email address enters the system:

# accounts/forms.py
from django import forms

from .validators import reject_disposable_email


class SignupForm(forms.Form):
    email = forms.EmailField(validators=[reject_disposable_email])
    password = forms.CharField(widget=forms.PasswordInput)


class ChangeEmailForm(forms.Form):
    email = forms.EmailField(validators=[reject_disposable_email])

You can also put it on a model field (models.EmailField(validators=[reject_disposable_email])), which gives you ModelForm and admin coverage for free. Be aware of the gap: Django doesn't run model validators on save(), only in full_clean(). Users created from a management command, a background job or a hand-written API view skip it unless you call full_clean() yourself.

If you use django-allauth

Allauth routes every address through its account adapter, which documents a clean_email hook for exactly this kind of restriction. In the current allauth source, the signup form, the add-email form and the password-reset form all call it, so one override covers the main entry points:

# accounts/adapter.py
from allauth.account.adapter import DefaultAccountAdapter

from .validators import reject_disposable_email


class AccountAdapter(DefaultAccountAdapter):
    def clean_email(self, email):
        email = super().clean_email(email)
        reject_disposable_email(email)
        return email
# settings.py
ACCOUNT_ADAPTER = "accounts.adapter.AccountAdapter"

Two things to check after you add this. First, because the password-reset form also goes through clean_email, existing users who signed up on a now-blocked domain will be told to use a permanent address when they try to reset their password. If you have such accounts, decide whether that is what you want, or make the override skip addresses that already belong to a user. Second, social logins don't go through these forms, so check how your provider setup handles email addresses from OAuth before assuming they're covered.

Step 3: FastAPI

In FastAPI the natural place is the Pydantic model, so the rule travels with the type. EmailStr needs the optional email-validator package installed (pip install email-validator).

# main.py
from typing import Annotated

from fastapi import FastAPI
from pydantic import AfterValidator, BaseModel, EmailStr

from disposable import is_disposable


def not_disposable(email: str) -> str:
    if is_disposable(email):
        raise ValueError("Please use a permanent email address.")
    return email


PermanentEmail = Annotated[EmailStr, AfterValidator(not_disposable)]


class SignupIn(BaseModel):
    email: PermanentEmail
    password: str


app = FastAPI()


@app.post("/signup", status_code=201)
async def signup(body: SignupIn):
    # ...create the user, send the verification email
    return {"ok": True}

A disposable address now gets FastAPI's standard 422 with loc: ["body", "email"]. Pydantic prefixes the message with Value error, , so if your frontend shows msg verbatim, strip that prefix or map on the error location instead. Reuse PermanentEmail in the email-change model and any invite model, and the rule stays in one place.

The check is a set lookup, so running it synchronously inside validation costs nothing measurable. Keep anything that does network I/O out of Pydantic validators, though. That's the next section.

Option: call an API from an async route

A domain list can't catch machine-generated addresses on normal domains (ccjxx90sze@...), and it is one more dependency to keep fresh. If you want both handled elsewhere, call a lookup API from the route, not from the model. With pingutil, skip_enrichment: true returns the category and verdict without company enrichment.

# screen.py
import os

import httpx

PINGUTIL_URL = "https://pingutil.com/v1/lookup"


async def screen_email(client: httpx.AsyncClient, email: str) -> dict:
    """Return {'block': bool, ...}. Never raises: outages fail open."""
    try:
        res = await client.post(
            PINGUTIL_URL,
            json={"input": email, "skip_enrichment": True},
            headers={"Authorization": f"Bearer {os.environ['PINGUTIL_KEY']}"},
            timeout=2.0,
        )
    except httpx.HTTPError as exc:
        return {"block": False, "degraded": type(exc).__name__}
    if res.status_code != 200:
        return {"block": False, "degraded": f"http_{res.status_code}"}
    data = res.json()
    verdict = data["verdict"]
    return {
        "block": verdict["is_spam"],
        "reason": verdict["reason"],
        "low_trust": verdict["low_trust"],
        "category": data["category"],
    }
# main.py (API variant)
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, EmailStr

from screen import screen_email


@asynccontextmanager
async def lifespan(app: FastAPI):
    async with httpx.AsyncClient() as client:
        app.state.http = client
        yield


app = FastAPI(lifespan=lifespan)


class SignupIn(BaseModel):
    email: EmailStr
    password: str


@app.post("/signup", status_code=201)
async def signup(body: SignupIn, request: Request):
    result = await screen_email(request.app.state.http, body.email)
    if result["block"]:
        raise HTTPException(status_code=422, detail="Please use a permanent email address.")
    # ...create the user; store result["low_trust"] / result["reason"] for review
    return {"ok": True}

One shared AsyncClient reuses connections across requests. Every non-200 response (401, 429, 5xx) and every timeout lets the signup through; log the degraded value so you notice. verdict.is_spam covers disposable domains and randomized local parts; low_trust marks role and system addresses like noreply@ that you may want to queue rather than reject. Keys are rate-limited to 60 requests per minute and there is no bulk endpoint, so this is for live signups, not for cleaning an existing table. Fields are documented in the API reference.

In Django, the same function works from an async view. From sync code, use httpx.Client with the same logic rather than wrapping the async version.

Tests worth having

# test_disposable.py
from disposable import is_disposable, registrable_domain


def test_listed_domain_and_subdomain():
    assert is_disposable("a@mailinator.com")
    assert is_disposable("a@Mail.YOPMAIL.fr")


def test_real_addresses_pass():
    assert not is_disposable("jane@gmail.com")
    assert not is_disposable("jane@stripe.com")


def test_idn_is_punycoded():
    assert registrable_domain("x@bücher.de") == "xn--bcher-kva.de"

Add one more test that fails if the installed list is suspiciously small (say, under 5,000 entries). It catches a broken upgrade before it reaches production.

Rollout checklist

  • One is_disposable function, imported by every path that writes an email address.
  • tldextract configured with suffix_list_urls=() in production.
  • Weekly automated upgrade of disposable-email-domains, gated on tests.
  • Users created outside forms go through full_clean() or the same validator.
  • Any network-based check fails open and logs.

Questions people ask

Does Pydantic's EmailStr block disposable email addresses?
No. EmailStr checks that the address is syntactically valid using the email-validator package. A Mailinator address is perfectly valid syntax, so you need a separate domain check.
Do Django model field validators run when I call save()?
No. Django runs model field validators in full_clean() and in ModelForms, not in save(). If you create users from a script or API view, call full_clean() or validate in the form or serializer layer.
Is the disposable-email-domains PyPI package kept up to date?
In October 2026 it was being released several times a week, tracking the GitHub list. You still only get updates when you upgrade the pinned version, so automate that.