Django 5 Email Validation: Going Beyond EmailField with API-Powered Verification
EmailField Validates Almost Nothing
You added models.EmailField() to your User model, ran makemigrations, and moved on. Django’s admin shows a nice “Enter a valid email address” error when you type garbage. Feels like validation.
It’s syntax checking. Nothing more.
EmailField runs Django’s EmailValidator under the hood. That validator checks for an @ sign, a dot somewhere in the domain, and rejects strings over 320 characters. [email protected] passes. [email protected] passes. [email protected] passes. Every one of these will bounce, cost you sender reputation, or self-destruct within the hour.
Here’s what Django’s EmailValidator actually exposes:
from django.core.validators import EmailValidator
# class EmailValidator(message=None, code=None, allowlist=None)
# Default allowlist: ["localhost"]
Three parameters. A custom error message, an error code, and a domain allowlist that defaults to ["localhost"]. No MX lookup. No SMTP handshake. No disposable domain detection. Email lists decay at 22-28% per year according to ZeroBounce’s annual benchmarks. Django’s regex will keep telling you those rotting addresses are valid the whole time.
What EmailValidator Catches (and Misses)
Django validates the local part and domain separately. The local part allows dot-atom format and quoted strings. The domain part checks for valid characters and handles internationalized names via IDNA encoding.
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# These all pass
validate_email("[email protected]") # Normal address
validate_email("o'[email protected]") # Apostrophe in local part
validate_email("[email protected]") # Plus addressing
# These raise ValidationError
validate_email("missing-at-sign.com") # No @ sign
validate_email("spaces [email protected]") # Space in local part
validate_email("@no-local-part.com") # Empty local part
What sails through? Domains with no MX records. Disposable email providers cycling through 180,000+ throwaway domains. Catch-all servers that accept mail for any address, real or fake. Typo domains like gmial.com that look structurally fine but won’t reach the intended recipient.
For a contact form on a brochure site? The regex is fine. For a SaaS where email deliverability is tied to revenue? You need three more layers.
Level Up: The email-validator Library
The email-validator package by Joshua Tauberer adds DNS-based deliverability checking on top of syntax validation. It looks for MX records (or fallback A/AAAA records) on the domain. No mail server means validation fails. That’s the big upgrade over bare Django.
# pip install email-validator
from email_validator import validate_email, EmailNotValidError
try:
result = validate_email("[email protected]", check_deliverability=True)
print(result.normalized)
except EmailNotValidError as e:
print(str(e)) # "The domain name fakecorp-xyz.com does not exist."
Wire it into a Django 5 form as a custom validator:
from django import forms
from email_validator import validate_email as ev_validate, EmailNotValidError
def validate_email_deliverability(value):
try:
ev_validate(value, check_deliverability=True)
except EmailNotValidError as e:
raise forms.ValidationError(str(e))
class SignupForm(forms.Form):
email = forms.EmailField(validators=[validate_email_deliverability])
The DNS lookup adds 50-200ms per validation. Acceptable for forms. But there’s no SMTP verification, no disposable detection, and no catch-all handling. The library can’t tell you if a mailbox actually exists. That’s a ceiling you can’t code around with local tools alone.
API-Powered Validation with MailCop
An email validation API checks what local libraries can’t: SMTP mailbox existence, real-time disposable domain lists, catch-all detection, and typo suggestions. The trade-off is an HTTP call.
Worth the trade-off? Do the math. A single bounced email costs nothing in isolation. A thousand bounces in a month pushes your bounce rate past 5%, and Gmail starts throttling your entire domain. One bad CSV import can tank deliverability for weeks. The API call costs a fraction of a cent. The cleanup costs real money.
Build a custom Django validator that wraps the API:
import requests
from django.conf import settings
from django.core.exceptions import ValidationError
def validate_email_api(value):
try:
response = requests.post(
"https://api.truemail.io/v1/verify",
headers={
"Authorization": f"Bearer {settings.TRUEMAIL_API_KEY}",
"Content-Type": "application/json",
},
json={"email": value, "checks": ["syntax", "mx"]},
timeout=3,
)
response.raise_for_status()
result = response.json()
if result["status"] == "undeliverable":
raise ValidationError("That email doesn't appear to be deliverable.")
if result.get("disposable"):
raise ValidationError("Disposable email addresses aren't allowed.")
except requests.Timeout:
pass # Fail open: don't block signup if API is slow
except requests.RequestException:
pass # Fail open: don't block signup if API is down
Wire it into your model:
from django.db import models
class User(models.Model):
email = models.EmailField(
unique=True,
validators=[validate_email_api],
)
The timeout=3 and the fail-open except blocks are the key design decisions. If the API doesn’t respond in 3 seconds, accept the email and verify later. Never let an external service become a single point of failure for registration.
DRF Serializer Integration
Building an API with Django REST Framework? Validation lives in serializers. DRF’s EmailField runs the same EmailValidator regex. Same blind spots.
A custom serializer field that calls the validation API on write:
from rest_framework import serializers
import requests
from django.conf import settings
class ValidatedEmailField(serializers.EmailField):
def to_internal_value(self, data):
email = super().to_internal_value(data)
try:
resp = requests.post(
"https://api.truemail.io/v1/verify",
headers={"Authorization": f"Bearer {settings.TRUEMAIL_API_KEY}"},
json={"email": email, "checks": ["syntax", "mx"]},
timeout=3,
)
if resp.ok:
result = resp.json()
if result["status"] == "undeliverable":
raise serializers.ValidationError(
"That email doesn't appear to be deliverable."
)
if result.get("disposable"):
raise serializers.ValidationError(
"Disposable email addresses aren't allowed."
)
except requests.Timeout:
pass
except requests.RequestException:
pass
return email
class SignupSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
email = ValidatedEmailField()
to_internal_value is where DRF runs field-level validation. By overriding it, you intercept the email before it reaches your view or model. The fail-open pattern stays identical.
For read endpoints that don’t need validation, use a regular EmailField. Don’t burn API calls on GET /users/.
Django 5 Async Views with httpx
Django 5 supports async views natively. If you’re already running under ASGI (Daphne, Uvicorn, Hypercorn), you can make non-blocking validation calls with httpx instead of requests. No thread pool overhead. No blocking the event loop.
# pip install httpx
import httpx
from django.http import JsonResponse
from django.views import View
from django.conf import settings
class ValidateEmailView(View):
async def post(self, request):
import json
body = json.loads(request.body)
email = body.get("email", "")
async with httpx.AsyncClient(timeout=3.0) as client:
try:
resp = await client.post(
"https://api.truemail.io/v1/verify",
headers={"Authorization": f"Bearer {settings.TRUEMAIL_API_KEY}"},
json={"email": email, "checks": ["syntax", "mx"]},
)
result = resp.json()
return JsonResponse({
"email": email,
"status": result["status"],
"disposable": result.get("disposable", False),
"catch_all": result.get("catch_all", False),
})
except httpx.TimeoutException:
return JsonResponse({"email": email, "status": "unknown"})
Wire it into your URLs:
from django.urls import path
from myapp.views import ValidateEmailView
urlpatterns = [
path("api/validate-email", ValidateEmailView.as_view()),
]
Why does this matter? Under WSGI (Gunicorn with sync workers), every validation API call ties up a worker thread for 100-400ms. With 4 workers and a burst of 20 signup requests, 16 of them queue up waiting. Under ASGI, those same 20 requests fire concurrently. The event loop handles the waiting. Your server handles the throughput.
The difference shows up under load. A Django app validating emails synchronously with requests maxes out at roughly 10-40 validations per second per worker (depending on API latency). The same app running async with httpx handles 200+ concurrent validations with a single process. That’s not a micro-optimization. That’s the difference between your signup form working during a marketing push and your server returning 502s.
Sync vs Async: When to Pick Which
Not every Django project needs async. If you’re running WSGI (the default), stick with requests and synchronous validators. Mixing async views into a WSGI deployment adds complexity without benefit, because Django runs them in a one-off event loop via async_to_sync, losing the concurrency advantage.
Go async when you’re already on ASGI and your validation endpoint handles concurrent traffic. Registration pages during a Product Hunt launch. Batch validation endpoints. Any view that makes multiple external HTTP calls.
The pattern that works for most teams: synchronous form validators for Django admin and server-rendered templates, async views for API endpoints that front-end JavaScript calls directly.
Async Batch Validation with Celery
Signup forms handle one email at a time. A CSV import of 50,000 contacts can’t block on 50,000 synchronous calls. Queue the heavy work.
from celery import shared_task
import requests
from django.conf import settings
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def validate_email_batch(self, contact_ids):
from myapp.models import Contact
for contact in Contact.objects.filter(id__in=contact_ids):
try:
resp = requests.post(
"https://api.truemail.io/v1/verify",
headers={"Authorization": f"Bearer {settings.TRUEMAIL_API_KEY}"},
json={"email": contact.email},
timeout=10,
)
if resp.ok:
result = resp.json()
contact.email_status = result["status"]
contact.email_disposable = result.get("disposable", False)
contact.email_catch_all = result.get("catch_all", False)
contact.save(update_fields=[
"email_status", "email_disposable", "email_catch_all"
])
except requests.RequestException as exc:
raise self.retry(exc=exc)
Enqueue in batches to stay within rate limits:
from myapp.tasks import validate_email_batch
contact_ids = list(
Contact.objects.filter(email_status="pending").values_list("id", flat=True)
)
for i in range(0, len(contact_ids), 100):
batch = contact_ids[i:i + 100]
validate_email_batch.delay(batch)
A 100,000-contact import at 80 requests per second takes about 20 minutes. Build that expectation into your UI. Show a progress bar backed by the email_status field count, not a spinner.
The max_retries=3 with default_retry_delay=60 handles transient API failures. If a batch fails, Celery retries it a minute later. Three strikes and it stops. Log those failures.
Caching Results with Django’s Cache Framework
Don’t hit the validation API every time a user resubmits your form after fixing a typo in their name. Cache it.
from django.core.cache import cache
import requests
from django.conf import settings
def validate_email_cached(email):
cache_key = f"email_validation:{email.lower()}"
cached = cache.get(cache_key)
if cached is not None:
return cached
try:
resp = requests.post(
"https://api.truemail.io/v1/verify",
headers={"Authorization": f"Bearer {settings.TRUEMAIL_API_KEY}"},
json={"email": email, "checks": ["syntax", "mx"]},
timeout=3,
)
if resp.ok:
result = resp.json()
ttl = 86400 if result["status"] == "deliverable" else 3600
cache.set(cache_key, result, ttl)
return result
except requests.RequestException:
return None
return None
Configure Redis as your cache backend:
# settings.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
}
}
Django 4.0+ ships RedisCache natively. No need for django-redis unless you want cache locks or compression. Set TTL to 24 hours for deliverable results and 1 hour for everything else. An email that was valid this morning is still valid tonight. One that bounced could get fixed, so give it a shorter window.
On a form that averages 1.8 submissions per signup (users correcting password or name fields), caching cuts validation API calls nearly in half.
MX vs SMTP: What Each Check Actually Tells You
Quick breakdown of what each validation layer proves, because the terminology gets muddled.
- Syntax (regex): the string looks like an email address. That’s it. No network call.
- MX lookup: the domain has mail servers configured. Doesn’t mean the specific mailbox exists.
- SMTP verification: the mail server confirms (or denies) that the specific mailbox accepts mail. This is the definitive check, but it takes 500ms-3s and some servers lie.
- Catch-all detection: identifies domains that accept mail for any address. The difference between MX and SMTP validation matters here, because MX says “this domain has a mail server” while SMTP says “this mailbox exists on that server.” Catch-all domains say “yes” to both, even for fake addresses.
The production pattern: run syntax + MX synchronously (under 400ms), queue SMTP for background verification. You catch dead domains and typos in real time, then deep-verify without blocking the form.
The Full Progression
Stage 1 (MVP). Django’s EmailField with default EmailValidator. Free, instant, catches syntax errors. Ship this on day one.
Stage 2 (Growing). Add email-validator with check_deliverability=True. DNS lookups catch dead domains. Adds 50-200ms per validation.
Stage 3 (Production). API-based validation with MailCop. Synchronous syntax + MX at signup, async SMTP + catch-all verification via Celery. Sub-400ms real-time path, full verification in background. Cache results in Redis. Async views with httpx if you’re on ASGI.
Where do you draw the line? If you’re pre-launch with 50 users, Stage 1 works. If you’re sending 50,000 transactional emails a month and your bounce rate is creeping past 3%, you needed Stage 3 yesterday.
Don’t over-engineer validation for a prototype. Don’t under-engineer it when bounces are costing you deliverability. The goal isn’t blocking every bad email at the door. It’s letting every real user through while catching the addresses that’ll hurt you downstream.