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.