Validating an email address in Go is three jobs wearing one name. The
tutorials that show you a regex and stop have solved exactly one of them — and
the least useful one at that. Real validation is layered: a cheap syntax check,
a DNS lookup, and an SMTP mailbox probe, cheapest first. Go's standard library
covers the first two cleanly with net/mail and net, no dependencies at all;
the third is a network problem worth handing off. This guide builds each layer
with working, idiomatic Go and shows exactly where a verification API takes over.
The short answer#
Use net/mail.ParseAddress for syntax, net.LookupMX for the MX lookup, and a
verification API for the SMTP mailbox check — in that order, cheapest first,
short-circuiting as soon as one layer is decisive. Don't reach for net/smtp to
probe mailboxes from your own server: outbound port 25 is blocked on most hosts,
and even where it isn't, the answer depends on sending-IP reputation and
greylisting behaviour you don't want to reimplement. Each layer rules addresses
out more cheaply than the last; only the final one can rule an address in.
Layer 1: syntax with net/mail#
Go has no need for a hand-rolled regex here. The standard library's net/mail
package parses RFC 5322 addresses, and ParseAddress returns a non-nil error on
anything malformed — which is exactly the yes/no signal a syntax gate needs:
package main
import "net/mail"
func isValidSyntax(email string) bool {
if len(email) > 320 {
return false
}
addr, err := mail.ParseAddress(email)
return err == nil && addr.Address == email
}
The addr.Address == email comparison is the part most examples miss.
ParseAddress is built to read a full mailbox line, so it happily accepts a
display name: mail.ParseAddress("Jane Doe <jane@example.com>") returns no
error, with addr.Address set to jane@example.com. For a signup field you
want the bare address and nothing else, so compare the parsed Address back
against the input and reject anything that carried a name, angle brackets, or
trailing whitespace:
isValidSyntax("jane@example.com") // true
isValidSyntax("Jane Doe <jane@example.com>") // false — not a bare address
isValidSyntax("not-an-email") // false — ParseAddress errors
isValidSyntax("a@@b.com") // false
A pass here means "worth checking", not "valid". It tells you the string is
shaped like an address; it says nothing about whether mail will arrive. That's
the same wall every regex-based email check runs
into, and net/mail sits on the same
side of it — it is a parser, not a deliverability oracle. Every layer below
assumes the syntax is already sane.
Layer 2: can the domain receive mail?#
This is where the standard library earns its keep with zero dependencies. A
domain that publishes no MX records has nowhere for mail to land, so one lookup
eliminates dead domains, misspelled company names, and invented TLDs.
net.LookupMX resolves them:
package main
import "net"
func hasMailRoute(domain string) bool {
mxs, err := net.LookupMX(domain)
if err != nil {
return false
}
return len(mxs) > 0
}
hasMailRoute("gmail.com") // true
hasMailRoute("company-that-folded.com") // false
LookupMX returns a slice of *net.MX sorted by preference, or a *net.DNSError
when the domain doesn't resolve. Treating any error — or an empty slice — as "no
mail route" is the safe reading for a validator. If you want to honour domains
that accept mail on an A record with no explicit MX (the so-called implicit MX),
fall back to net.LookupHost(domain) when the MX slice comes back empty. For the
overwhelming majority of real addresses, though, an MX check is the right filter.
One operational note: LookupMX blocks on your DNS resolver, so a slow or
unreachable nameserver stalls the goroutine. In a request path, use the
context-aware resolver — (&net.Resolver{}).LookupMX(ctx, domain) — with a
deadline, and treat a timeout as "check later", not a hard reject, so a transient
DNS hiccup never turns a real customer away.
Layer 3: does the mailbox actually exist?#
Layers 1 and 2 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 was never a
real mailbox. 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.
Go ships the tools to try it. net/smtp gives you smtp.Dial and a Client
with Mail, Rcpt, and Close methods, and you could in principle drive that
handshake yourself. In practice you shouldn't run it from your application
server. Most cloud providers block outbound port 25 outright, so the dial simply
times out in production even when it worked on your laptop. Where the port is
open, the answer depends on the reputation of the IP you connect from, and
receiving servers greylist and rate-limit unfamiliar senders — so a naive probe
gets deferred, throttled, or blocklisted. There's more to it than one round-trip,
too: catch-all domains accept every address and defeat a single RCPT TO.
How email verification works walks the full
pipeline. This is the layer worth delegating.
Doing the full check with an API#
Qualisend's real-time API runs the whole pipeline — syntax, DNS, and the SMTP
mailbox probe — from reputation-managed infrastructure built for it, and hands
back a verdict. It's a plain JSON POST, so net/http and encoding/json from
the standard library are all you need. Decode the result envelope straight
into a struct:
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
type VerifyResult struct {
Status string `json:"status"` // deliverable | risky | undeliverable | unknown
Score int `json:"score"`
Reason string `json:"reason"`
SubFlags map[string]bool `json:"sub_flags"`
}
type verifyResponse struct {
Result VerifyResult `json:"result"`
}
func verify(ctx context.Context, email string) (VerifyResult, error) {
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return VerifyResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.qualisend.com/v1/verify", bytes.NewReader(body))
if err != nil {
return VerifyResult{}, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("QUALISEND_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return VerifyResult{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return VerifyResult{}, fmt.Errorf("qualisend: unexpected status %s", res.Status)
}
var payload verifyResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return VerifyResult{}, fmt.Errorf("qualisend: decode response: %w", err)
}
return payload.Result, nil
}
The endpoint and key above are placeholders — read your scoped key from the
QUALISEND_API_KEY environment variable rather than hard-coding it, and check
the API reference on the developers page for the exact request and
response shape. The result object carries a status of deliverable,
risky, undeliverable, or unknown, a numeric score, a reason, and a
sub_flags map (role address, disposable, free provider, catch-all, and the
like) — the fields you actually branch on.
Because verify takes a context.Context, the caller owns the timeout and
cancellation. Pass a deadline so a slow verification never hangs a signup request:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := verify(ctx, "jane@example.com")
if err != nil {
log.Printf("verify failed: %v", err)
}
Putting it together to validate an email address in Go#
Cheapest first, stop as soon as you have an answer. The two local layers cost nothing and reject most junk instantly; the API call runs only on addresses that survived them and are worth the network round-trip:
package main
import (
"context"
"strings"
)
func validateEmail(ctx context.Context, email string) (VerifyResult, error) {
if !isValidSyntax(email) {
return VerifyResult{Status: "undeliverable", Reason: "invalid_email"}, nil
}
domain := email[strings.LastIndex(email, "@")+1:]
if !hasMailRoute(domain) {
return VerifyResult{Status: "undeliverable", Reason: "invalid_domain"}, nil
}
return verify(ctx, email)
}
Then branch on the status the way your product needs — reject the undeliverables, let the deliverables through, and decide per feature what to do with the greyer verdicts:
result, err := validateEmail(ctx, "jane@example.com")
if err != nil {
// network/API failure — fail open or retry, don't block a real user
}
switch result.Status {
case "deliverable":
// accept
case "undeliverable":
// reject at the form
default:
// risky | unknown — flag for review, or allow with a soft warning
}
That ordering is the whole trick, and it's the same shape you'll find in the Node.js and Python versions of this guide — the layering, not the language, is what makes validation reliable. See how email verification works for why each stage sits where it does.
Frequently asked questions#
Is net/mail.ParseAddress enough to validate an email address in Go?#
For syntax, yes — net/mail.ParseAddress is the right layer-one check and a far
better bet than a hand-rolled regex, since it parses against RFC 5322 and errors
on malformed input. But it validates shape, not deliverability: it never
resolves DNS or contacts a mail server, so a nil error means "looks like an
address", not "will deliver". Compare addr.Address back against your input to
reject display names, then pair the check with an MX lookup and an SMTP mailbox
probe.
How do I look up MX records in Go?#
Use net.LookupMX(domain) from the standard library. It returns a slice of
*net.MX records sorted by preference, or a *net.DNSError when the domain
doesn't resolve. Treat an error or an empty slice as "no mail route", and in a
request path use the context-aware (*net.Resolver).LookupMX(ctx, domain) so a
slow nameserver can't stall the goroutine past your deadline.
Can I verify a mailbox in Go without an external service?#
Partly. net.LookupMX confirms the domain accepts mail, which rules out dead
domains for free and needs nothing beyond the standard library. Confirming the
mailbox itself means an SMTP conversation, which you can script with net/smtp
but shouldn't run from your app server — port 25 is widely blocked and the
result depends on your sending IP's reputation. That's the layer a verification
service exists to handle; you can see the difference on any address with the
free email checker.
Should I validate emails at signup or when cleaning a list?#
Both, at different depths. Run syntax and the MX lookup synchronously at signup — they're fast enough to block the request and give instant feedback — and act on the immediate API verdict there too. Reserve the deeper SMTP-confirmed work for list cleaning and back-office jobs; the serverless signup guide shows the real-time pattern end to end.
Ready to add the SMTP layer to your Go service? The
Qualisend API reference has the /verify endpoint with scoped
keys and copy-paste examples, and the free email checker
lets you watch a syntactically perfect address come back undeliverable before
you write a line of code.