# Validate an email address in Django past the syntax check

_2026-10-07 · Boundstone (https://boundstone.io/blog/validate-email-django)_


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:

```python
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](/blog/what-is-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.

```python
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:

```python
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:

```python
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:

```python
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:

```json
"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](/tools/email-validator). Not on Django, or want the framework-free version? See [validate an email in Python](/blog/validate-email-python).
