# Validate an email address in Python: regex, stdlib, and API

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


Someone hands you a signup form and asks you to "validate the email." You reach for a regex, ship it, and move on. Then the bounces start, the disposable accounts pile up, and you learn the hard way that "looks like an email" and "can receive mail" are two very different claims.

Here's how to check an email address in Python, layer by layer — with an honest account of what each layer actually proves. Because the mistake isn't using a regex; it's not knowing what your check does and doesn't cover.

## Layer 1: the regex (format only)

This is what most people mean by "validate an email," and for a lot of cases it's the right amount of effort:

```python
import re

EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

def looks_like_email(value: str) -> bool:
    return bool(EMAIL_RE.match(value.strip()))
```

You'll find people insisting you need the "full" RFC 5322 regex instead. Don't. It's a famous multi-thousand-character monster, it *still* accepts addresses that will never receive mail, and it rejects some that would. A short, strict-enough pattern plus a length cap (addresses max out at 254 characters) catches the fat-finger typos, and that's all a regex can honestly promise.

What this proves: the string is *shaped* like an address. That's it. `test@nonexistent-domain-9f2b.com` passes this every time and bounces every time. The regex has no idea whether the domain exists, let alone the mailbox.

## Layer 2: the standard-library trap

A tempting move is to reach into Python's `email` module:

```python
from email.utils import parseaddr

name, addr = parseaddr("Sarah <sarah@acme.com>")
# addr == "sarah@acme.com"
```

`parseaddr` is genuinely useful — but it is **not** a validator. It *parses* an address out of a header; hand it pure garbage and it will happily hand a chunk of that garbage back to you without complaint. If you're using `parseaddr` as your validation, you don't have validation. Use it to extract, then validate what you extracted.

## Layer 3: a real library, and a live MX check

When the regex isn't enough, don't hand-roll RFC parsing — use a maintained library. The [`email-validator`](https://pypi.org/project/email-validator/) package does correct syntax validation, normalizes the address, and can do a live deliverability check:

```python
from email_validator import validate_email, EmailNotValidError

try:
    info = validate_email("you@example.com", check_deliverability=True)
    normalized = info.normalized   # canonical form, safe to store
except EmailNotValidError as err:
    print("rejected:", err)
```

With `check_deliverability=True`, it resolves the domain's mail servers — the single most valuable check past syntax. No MX (or A) record means no mailbox can receive mail there, whatever the syntax says. If you'd rather do just that step yourself, [dnspython](https://pypi.org/project/dnspython/) 2.x is one call:

```python
import dns.resolver

def has_mail_servers(domain: str) -> bool:
    try:
        return len(dns.resolver.resolve(domain, "MX")) > 0
    except dns.resolver.NoAnswer:
        # some domains accept mail on their A record with no MX
        return bool(dns.resolver.resolve(domain, "A"))
    except Exception:
        return False
```

What this proves: the domain can receive mail. A big step up. What it still doesn't tell you: whether the *specific mailbox* exists, whether the domain is a disposable burner, or whether it's a shared role alias — three things that matter enormously at a signup form and not at all to a DNS resolver.

## Layer 4: the checks local code can't do

You can't tell from Python alone that `x7f2@mailinator.com` is a throwaway inbox, or that `admin@acme.com` is a shared alias rather than a person. Those need lists and lookups maintained somewhere off your box — which is where a validation API earns its place:

```python
import requests

r = requests.post(
    "https://api.boundstone.io/v1/verify/email",
    headers={"authorization": "Bearer bs_live_YOUR_KEY"},
    json={"email": "you@example.com"},
    timeout=5,
)
data = r.json()

print(data["valid_syntax"], data["mx_found"], data["disposable"], data["role_account"])
```

One call gives you syntax, a live MX lookup, disposable-domain detection, role-account and free-provider flags. If you just want to try it without writing any code, the [free email validator](/tools/email-validator) runs the exact same checks in the browser.

## The part most vendors skip

Here's the thing to internalize, and it's the whole reason we built Boundstone the way we did: **no email check — not the regex, not the library, not the API — can prove a mailbox exists without a live SMTP probe, and SMTP probes are unreliable enough that a "yes" from one often means nothing.** A large share of mail servers accept-then-bounce, or answer positively to every address (a "catch-all").

So the honest thing an API can do is tell you *what it actually checked*. Every Boundstone response carries that, explicitly:

```python
print("checked:    ", data["checks"]["performed"])
# ['syntax', 'mx', 'disposable_list', 'role_list']

print("not checked:", data["checks"]["not_performed"])
# ['smtp_mailbox', 'catch_all']
```

That second list is the point. A validator that returns a confident `valid: true` and won't tell you whether that includes a mailbox check is asking you to trust a word. One that hands you `not_performed: ["smtp_mailbox", "catch_all"]` is telling you exactly how far to trust the verdict — which is the only kind of trust worth anything.

## Which layer do you actually need?

| Layer | Proves | Cost | Reach for it when |
|---|---|---|---|
| Regex | Format only | Instant, free | Catching typos in a form field |
| `email-validator` | Format + MX | A dependency, a DNS call | You control the code path and want no external service |
| API | Format + MX + disposable + role | One HTTP call | Signup screening, list hygiene, anything user-facing |

Start at the top and go down only as far as the problem needs. A newsletter field is fine with a regex. A paid-signup form that fraudsters probe with disposable inboxes wants Layer 4. The failure mode isn't picking the wrong layer — it's shipping a check and forgetting what it doesn't cover.

Whatever you choose, store the normalized form, handle the "we didn't check that" cases explicitly, and never let a green checkmark stand in for a claim nobody actually verified. The full API reference — every field, every `not_performed` case — is in the [docs](/docs).
