Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / June 11, 2026

How to validate an email address in Django

7 minutes read

Qualisend team
Code window titled validators.py showing three narrowing Django validation layers — validate_email, an MX resolve, and a POST to the verify API — ending in a green deliverable badge.

To validate an email address in Django you run three checks, not one — and the framework only ships the first. Django's validators confirm an address is shaped correctly; they never ask whether its domain can receive mail or whether the mailbox exists. Real validation is layered: syntax, then a DNS lookup, then an SMTP mailbox probe. This guide builds each layer as a Django validator you can drop into a form or a Django REST Framework serializer, and shows where a verification API takes over. It picks up where the Python guide leaves off, so the DNS and SMTP reasoning there carries over intact.

The short answer#

Use validate_email (or any EmailField) for syntax, dnspython for the MX lookup, and a verification API for the SMTP mailbox check — wired as three validators, cheapest first, short-circuiting as soon as one is decisive. Don't open SMTP connections from your Django process to probe mailboxes: outbound port 25 is blocked on most hosts, and the answer depends on sending-IP reputation and greylisting you don't want to reimplement. Each layer rules addresses out more cheaply than the last; only the API can rule one in. If the pipeline is new to you, what email verification is covers the terms first.

Layer 1: format validation with Django's validators#

Django already owns layer one. django.core.validators.validate_email is an EmailValidator instance that raises ValidationError on a malformed address and returns None on a good one:

from django.core.validators import validate_email
from django.core.exceptions import ValidationError

def is_valid_syntax(email: str) -> bool:
    try:
        validate_email(email)
        return True
    except ValidationError:
        return False

You rarely call it by hand. Every forms.EmailField, models.EmailField, and DRF serializers.EmailField attaches EmailValidator automatically, so declaring a field gives you syntax validation for free:

from django import forms

class SignupForm(forms.Form):
    email = forms.EmailField()  # EmailValidator runs during clean()

Know exactly what this buys you. EmailValidator checks shape against an RFC-derived grammar — it never resolves DNS, never opens a socket, and has no idea whether example.com exists. definitely-fake@gmail.com passes. info@company-that-folded.com passes. typo@gmial.com passes. All three are undeliverable, and no validator that only reads the string will ever tell you so, for the same reason regex email validation fails: syntax and deliverability are different questions. One is a fact about the string; the other is a fact about the internet.

Layer 2: does the domain accept mail?#

This is the first layer Django doesn't hand you, and it's cheap to bolt on. A domain with no MX records can't accept mail for anyone, so one DNS lookup eliminates dead domains, misspelled company names, and invented TLDs. Django has no built-in MX resolver, so reach for dnspython and wrap it in a plain function:

import dns.resolver  # pip install dnspython

def has_mail_route(domain: str) -> bool:
    try:
        return len(dns.resolver.resolve(domain, "MX")) > 0
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
        return False

A Django validator is just a callable that raises ValidationError on bad input, so promote the check into one and it plugs straight into any field's validators list:

from django.core.exceptions import ValidationError

def validate_deliverable_domain(email: str) -> None:
    domain = email.rsplit("@", 1)[-1]
    if not has_mail_route(domain):
        raise ValidationError("This domain can't receive email.")
validate_deliverable_domain("jane@gmail.com")               # passes
validate_deliverable_domain("jane@company-that-folded.com") # raises ValidationError

If you want to honour domains that accept mail on an A record with no MX, fall back to resolving A/AAAA when the MX set is empty — but an MX check covers the vast majority of real addresses.

Layer 3: does the mailbox actually exist?#

Layers one and two can only rule an address out. A domain can publish perfect MX records and still have no mailbox at the address you're holding — noreply-9f2x@gmail.com is valid syntax on a live mail route, and it's still a mailbox that was never created. Confirming a specific mailbox means the SMTP delivery conversation: connect to the mail host, issue RCPT TO, read the reply, and disconnect before sending anything.

In principle you could script that from Django with smtplib. In practice you shouldn't run it from your application server: most cloud hosts block outbound port 25, the answer depends on the reputation of the IP you connect from, and receiving servers greylist and rate-limit unfamiliar senders — so a probe that works in a local test quietly fails, or gets you blocklisted, in production. How email verification works walks the full pipeline, catch-all domains and all. This is the layer worth delegating.

Qualisend's verify endpoint runs the whole pipeline — syntax, DNS, and the SMTP mailbox probe — from reputation-managed infrastructure and returns a verdict. Call it from a validator with requests, reading your key from the environment:

import os
import requests  # pip install requests

QUALISEND_VERIFY_URL = "https://api.qualisend.com/v1/verify"

def verify(email: str) -> dict:
    res = requests.post(
        QUALISEND_VERIFY_URL,
        headers={"Authorization": f"Bearer {os.environ['QUALISEND_API_KEY']}"},
        json={"email": email},
        timeout=10,
    )
    res.raise_for_status()
    return res.json()["result"]  # {"status", "score", "reason", "sub_flags"}

The response is a { "result": { ... } } envelope. Read result["status"]deliverable, risky, undeliverable, or unknown — alongside a score, a reason, and sub_flags for traits like role or disposable addresses. Check the API reference for the exact field shapes; here you only need the status to raise on an address that won't deliver:

def validate_mailbox(email: str) -> None:
    if verify(email)["status"] == "undeliverable":
        raise ValidationError("We couldn't confirm a mailbox at this address.")

You might also raise on risky, or store the score and let it through — that's a policy call. Rejecting undeliverable is the safe default; the serverless signup guide covers how strict to be at the point of collection.

Putting the layers together to validate an email address in Django#

The idiomatic place to compose the three layers on a form is a clean_<field> method. Django calls clean_email only after the field's own EmailValidator has passed, so syntax is already handled; you add the domain and mailbox checks in order, each raising early:

from django import forms

class SignupForm(forms.Form):
    email = forms.EmailField()  # layer 1 for free

    def clean_email(self):
        email = self.cleaned_data["email"]  # syntax already passed
        validate_deliverable_domain(email)  # layer 2 — cheap, local
        validate_mailbox(email)             # layer 3 — the API round-trip
        return email

That ordering is the whole trick: the field's EmailValidator short-circuits junk before your code runs, the local DNS check rules out dead domains for nothing, and the API is hit only for addresses that cleared both. Wrong-order validation — or skipping the cheap layers — spends a credit on every typo.

One thing to decide up front: what happens when the API call itself fails. A network timeout or a requests exception inside clean_email shouldn't hand a real customer a validation error they can't fix. Catch the request failure separately from ValidationError, and treat an unreachable service as unknown rather than undeliverable — let the signup through and re-verify the address later, instead of blocking registration on a transient outage. The syntax and MX layers already ran locally, so you're only softening the layer that depends on the network.

Because both custom validators raise Django's ValidationError, the exact same two functions drop into a Django REST Framework serializer unchanged. DRF's validate_<field> hook catches that exception and turns it into a clean 400:

from rest_framework import serializers

class SignupSerializer(serializers.Serializer):
    email = serializers.EmailField()  # layer 1 for free

    def validate_email(self, value):
        validate_deliverable_domain(value)  # layer 2
        validate_mailbox(value)             # layer 3
        return value

This is the same three-layer shape you'll find in the Node.js and Python versions of this guide — the layering, not the framework, is what makes validation work. When you're choosing which verification service backs layer three, the API comparison lines up the options.

Frequently asked questions#

Is Django's EmailValidator enough to validate an email address?#

For syntax, yes — validate_email (and every EmailField that uses it) is the right layer-one check and better than a hand-rolled regex. But it validates shape, not deliverability: it never resolves DNS or contacts a mail server, so a pass means "looks like an email," not "will deliver." Pair it with an MX lookup and an SMTP mailbox check before you trust the address.

How do I write a custom email validator in Django?#

A validator is any callable that takes the value and raises django.core.exceptions.ValidationError on failure. Define a function like validate_deliverable_domain(email) that raises when there's a problem and returns None otherwise, then pass it in the field's validators=[...] list or call it from a form's clean_email method. The same callable works in a DRF serializer's validate_email hook, because DRF also catches Django's ValidationError.

Can I check whether a mailbox exists from Django without an API?#

Partly. dnspython confirms the domain accepts mail, which rules out dead domains for free and needs nothing beyond a small dependency. Confirming the mailbox means an SMTP conversation you can attempt with smtplib but shouldn't run from your app server — port 25 is widely blocked and the result depends on your IP's reputation. That's the layer a verification service exists to handle.

Should I run these checks at signup or when cleaning a list?#

Both, at different depths. Run the syntax and MX layers synchronously in clean_email — they're fast enough to block the request and give instant feedback — and act on the API verdict there too. Reserve heavier batch verification for list cleaning and back-office work, where latency doesn't matter and you can process addresses in bulk.


Ready to add the SMTP layer? Drop a syntactically perfect address into the free email checker to watch an EmailField-approved string come back undeliverable, then wire the same verdict into your forms with the API reference — copy-paste examples included.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started