← Blog

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.EmailField and validate_email check 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_performed lets 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.

Frequently asked questions

Does Django's EmailField or EmailValidator confirm that an email address actually exists?

No. Django's EmailField and EmailValidator only check that the address is well-formed — the right shape of local-part, @ symbol, and domain. They do not check whether the domain can receive mail or whether the specific mailbox exists. To go past the syntax check you need to inspect the domain's DNS records and screen for throwaway or role addresses, which is what a validation API adds on top of Django's form-level check.

How can I check whether an email's domain can actually receive mail from a Django app?

You verify the domain's MX (mail exchange) records over DNS before accepting the address. Boundstone's email endpoint does this for you — on a valid address it performs syntax, MX, disposable-list, and role-account checks and returns mx_found, disposable, role_account, and free_provider flags you can branch on in your Django view. It deliberately does not perform SMTP mailbox probing or catch-all detection, so it never opens a sending connection to the recipient's mail server.

If an email passes validation, does that guarantee it will deliver?

No, and it is worth designing your signup flow around that. A valid result means the syntax is correct, the domain has MX records, and the address is not on a disposable or role list — it does not prove the individual mailbox exists or that the server will accept your message, because SMTP mailbox verification and catch-all detection are not performed. Boundstone spells out exactly which checks ran under checks.performed and which did not under checks.not_performed, so you can decide whether to accept, flag, or send a double opt-in rather than assuming deliverability.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist