Validate an email address in Django past the syntax check
Django validates the shape of an address; here's how to add the deliverability layer it leaves out — MX, disposable, and role checks — with a custom validator or clean_email().
Contents
You added email = forms.EmailField() to your Django form, the form rejects not-an-email, and you moved on. A week later your database has asdf@asdf.com, a pile of @mailinator.com throwaways, and three info@ role addresses nobody reads. That is the gap this post closes: how to validate an email address in Django past the point where the built-in field stops.
What EmailField actually checks
forms.EmailField runs one thing under the hood: django.core.validators.validate_email, an instance of EmailValidator. It checks that the string looks like an address — a local part, an @, a domain with at least one dot, no spaces, sane length. That is a syntax check, and nothing more.
It does not look up the domain. It does not ask whether gmial.com can receive mail, whether the domain is a disposable throwaway, or whether the inbox is a shared role account. validate_email will happily pass wizard@hogwarts.invalid because the shape is fine.
For a lot of forms, that is genuinely all you need. If you are collecting an address you will email once and never depend on, the syntax check does the job — don't bolt a network call onto a form that doesn't warrant one. This post is for the other case: signups, lead capture, anywhere a fake or dead address costs you later.
The two validators, and where they stop
You will see two names in Django code, and both stop at syntax:
from django.core.validators import validate_email, EmailValidator
validate_email("someone@example.com") # raises ValidationError if the shape is wrong
EmailValidator()("someone@example.com") # same check, callable form
Neither performs a DNS lookup. If you want to know whether the domain actually has a mail exchanger — an MX record — you have to ask DNS yourself, or ask something that asks DNS for you.
Add the deliverability layer with a custom validator
A Django validator is just a callable that raises ValidationError on bad input. Here is one that calls Boundstone's email endpoint and enforces three things the syntax check can't: a live MX record, not disposable, not a role account.
import requests
from django.core.exceptions import ValidationError
BOUNDSTONE_KEY = "bs_live_YOUR_KEY"
def validate_deliverable_email(value):
try:
resp = requests.post(
"https://api.boundstone.io/v1/verify/email",
headers={"Authorization": f"Bearer {BOUNDSTONE_KEY}"},
json={"email": value},
timeout=5,
)
resp.raise_for_status()
except requests.RequestException:
# Network trouble isn't the user's fault — don't block signup on it.
return
data = resp.json()
if not data["mx_found"]:
raise ValidationError("This domain can't receive email — check for a typo.")
if data["disposable"]:
raise ValidationError("Disposable email addresses aren't accepted.")
if data["role_account"]:
raise ValidationError("Use a personal address, not a shared one like info@ or support@.")
The response also carries valid_syntax, domain, and free_provider. That last flag is worth a word: it tells you the address sits on a consumer mailbox provider like Gmail or Outlook. It is a signal, not a verdict — plenty of real customers use Gmail — so read it if you want to route or score, but don't reject on it.
Attach the validator to the field. EmailField runs its own syntax check first, so by the time your validator sees the value, the shape is already good:
from django import forms
class SignupForm(forms.Form):
email = forms.EmailField(validators=[validate_deliverable_email])
Or do it in clean_email()
If you would rather keep the logic on the form — say you need the request or another field in scope — put it in clean_email(). Same checks, different seam:
class SignupForm(forms.Form):
email = forms.EmailField()
def clean_email(self):
email = self.cleaned_data["email"]
resp = requests.post(
"https://api.boundstone.io/v1/verify/email",
headers={"Authorization": f"Bearer {BOUNDSTONE_KEY}"},
json={"email": email},
timeout=5,
)
data = resp.json()
if not data["mx_found"]:
raise forms.ValidationError("This domain can't receive email.")
if data["disposable"]:
raise forms.ValidationError("Disposable addresses aren't accepted.")
return email
The same thing in a DRF serializer
Django REST Framework uses the identical pattern — a validate_<field> method that returns the value or raises:
from rest_framework import serializers
class SignupSerializer(serializers.Serializer):
email = serializers.EmailField()
def validate_email(self, value):
resp = requests.post(
"https://api.boundstone.io/v1/verify/email",
headers={"Authorization": f"Bearer {BOUNDSTONE_KEY}"},
json={"email": value},
timeout=5,
)
data = resp.json()
if not data["mx_found"]:
raise serializers.ValidationError("This domain can't receive email.")
if data["disposable"]:
raise serializers.ValidationError("Disposable addresses aren't accepted.")
return value
What "valid" means here — and what it doesn't
Boundstone tells you exactly what it did. Every response carries a checks object. For a well-formed address the performed list is:
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
Read not_performed before you trust a pass. Boundstone does not open an SMTP conversation with the mail server to confirm the specific mailbox exists, and it does not probe for catch-all domains. So a clean result means the domain can receive mail and the address isn't disposable or a role account — not that jane.doe@ in particular is a live inbox. Nobody can promise the latter without actually sending mail, and anyone who says otherwise is guessing. The not_performed list is there so you know precisely where the confidence ends.
The short version
forms.EmailFieldandvalidate_emailcheck syntax only — no MX, no disposable, no role. For throwaway forms, that's fine.- For signups, add a custom validator or a
clean_email()that calls the API for MX + disposable + role. - DRF is the same move in
validate_email(). - Trust a "valid" only as far as
checks.not_performedlets you: no mailbox-level or catch-all check, by design.
Want to eyeball an address before you wire anything up? Paste one into the free email validator. Not on Django, or want the framework-free version? See validate an email in Python.