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

How to validate an email address in Flask

8 minutes read

Qualisend team
Code window titled forms.py showing three narrowing Flask validation layers — Email(), a check_deliverability MX lookup, and a POST to the verify API — ending in a green deliverable badge.

To validate an email address in Flask you run three checks, not one — and Flask itself ships none of them, because Flask has no forms layer of its own. What it has is an ecosystem: WTForms (usually through Flask-WTF) for form handling, and the email-validator package that WTForms leans on. Between them they answer the first two of validation's three questions — is the address shaped correctly, and can its domain receive mail — but never the third: does the mailbox actually exist. Real validation is layered: syntax, then a DNS lookup, then an SMTP mailbox probe. This guide builds each layer as a WTForms validator you can drop into a Flask-WTF form or a plain JSON route, 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 WTForms' Email() validator for syntax, its check_deliverability=True option (or dnspython) for the MX lookup, and a verification API for the SMTP mailbox check — wired cheapest-first so each layer short-circuits before the next runs. Don't open SMTP connections from your Flask 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 WTForms' Email validator#

WTForms owns layer one, and it doesn't hand-roll it. wtforms.validators.Email delegates to the email-validator package, so declaring an Email() validator on a field gives you a real, RFC-derived syntax check — not a regex — for free:

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Email

class SignupForm(FlaskForm):
    email = StringField("Email", validators=[DataRequired(), Email()])

The Email() validator needs the package installed (pip install email-validator, or pip install wtforms[email]); without it WTForms raises at validation time telling you so. Under the hood it calls email_validator.validate_email, which you can also use standalone — outside a form, or in a service layer — to get the same verdict as a boolean:

from email_validator import validate_email, EmailNotValidError

def is_valid_syntax(email: str) -> bool:
    try:
        validate_email(email, check_deliverability=False)
        return True
    except EmailNotValidError:
        return False

Know exactly what this buys you. With check_deliverability=False the check reads shape only — 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?#

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. Here Flask's stack does something Django's doesn't do out of the box: email-validator can run the lookup for you. Flip check_deliverability on and it resolves the domain's MX records (falling back to A/AAAA) with dnspython, raising EmailUndeliverableError — a subclass of EmailNotValidError — when the domain can't receive mail. On a form, that's one option on the validator you already have:

email = StringField("Email", validators=[DataRequired(), Email(check_deliverability=True)])

That single flag folds layers one and two into one validator. Standalone, the same call returns a normalized-address object when the domain checks out:

from email_validator import validate_email, EmailNotValidError

def has_mail_route(email: str) -> bool:
    try:
        validate_email(email, check_deliverability=True)  # syntax + live MX lookup
        return True
    except EmailNotValidError:
        return False

If you'd rather run the DNS check yourself — to cache results, point at your own resolver, or set a tight timeout — drop straight to dnspython:

import dns.resolver  # pip install dnspython

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

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 Flask 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 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 decide. Rejecting undeliverable is the safe default; you might also reject risky, or store the score and let it through — that's a policy call, and the serverless signup guide covers how strict to be at the point of collection.

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

The idiomatic place to add a per-field check in Flask-WTF is an inline validate_<fieldname> method on the form. WTForms appends that method to the field's validator chain and runs it after the validators in the list, so by the time it fires, Email(check_deliverability=True) has already settled syntax and the MX lookup. Guard on field.errors so the API round-trip only happens for addresses that cleared both cheaper layers:

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Email, ValidationError

class SignupForm(FlaskForm):
    email = StringField("Email", validators=[
        DataRequired(),
        Email(check_deliverability=True),   # layers 1 + 2
    ])

    def validate_email(self, field):        # layer 3 — runs after the list above
        if field.errors:                    # syntax or MX already failed
            return                          # don't spend an API credit
        if verify(field.data)["status"] == "undeliverable":
            raise ValidationError("We couldn't confirm a mailbox at this address.")

That field.errors guard is the whole trick. A malformed address or a dead domain records an error in the chain before the inline method runs, so the guard stops the verify call on addresses that never had a chance — wrong-order validation, or skipping the guard, spends a credit on every typo. In a view, validate_on_submit runs the whole pipeline before your handler body executes:

@app.route("/signup", methods=["POST"])
def signup():
    form = SignupForm()
    if form.validate_on_submit():
        # every layer passed — safe to persist
        create_account(form.email.data)
        return redirect(url_for("welcome"))
    return render_template("signup.html", form=form), 400

One thing to decide up front: what happens when the API call itself fails. A network timeout or a requests exception inside validate_email shouldn't 500 the request or hand a real customer an error they can't fix. Catch requests.RequestException 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, so you're only softening the layer that depends on the network.

Not every Flask app uses WTForms. For a JSON API the same three layers read straight in a route, returning a 400 on a bad address instead of raising ValidationError:

from flask import Flask, request, jsonify
from email_validator import validate_email, EmailNotValidError

app = Flask(__name__)

@app.post("/api/signup")
def api_signup():
    email = (request.get_json(silent=True) or {}).get("email", "")
    try:
        info = validate_email(email, check_deliverability=True)  # layers 1 + 2
    except EmailNotValidError as exc:
        return jsonify(error=str(exc)), 400

    if verify(info.normalized)["status"] == "undeliverable":     # layer 3
        return jsonify(error="Email address appears undeliverable."), 400

    # ... create the account
    return jsonify(ok=True), 201

info.normalized is the canonicalised address email-validator hands back, which is what you should store. 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 WTForms' Email validator enough to validate an email address?#

For syntax, yes — Email() delegates to the email-validator package and is a better layer-one check than any hand-rolled regex. But by default it validates shape, not deliverability. Add check_deliverability=True and it also runs a live MX lookup, which rules out dead domains; even then it never contacts the mailbox, so a pass means "the domain accepts mail," not "this address exists." Pair it with an SMTP mailbox check before you trust the address.

How do I add a custom email validator in Flask-WTF?#

Two idiomatic options. For a one-off check, add an inline method named validate_<fieldname> to the form — e.g. validate_email(self, field) — and raise wtforms.validators.ValidationError when the value fails; WTForms runs it after the field's listed validators. For a reusable rule, write a callable that takes (form, field) and raises the same error, then pass it in the field's validators list. Either one is where the Qualisend verify call belongs.

Can I check whether a mailbox exists in Flask without an API?#

Partly. email-validator with check_deliverability=True (or dnspython directly) 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 validate emails at signup or when cleaning a list?#

Both, at different depths. Run the syntax and MX layers synchronously in the form or route — 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 rather than one form submission at a time.


Ready to add the SMTP layer? Drop an Email()-approved address into the free email checker to watch a WTForms-valid string come back undeliverable, then wire the same verdict into your form 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