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

How to validate an email address in Go

6 minutes read

Qualisend team
A layered diagram of a Go email address passing through net/mail.ParseAddress syntax validation, a net.LookupMX MX lookup, and an SMTP mailbox probe, narrowing to one deliverable verdict

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.

Your reputation, protected.

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

Get started