To validate email in Golang, most code stops at mail.ParseAddress, and that function accepts far more than a sign-up form should: display names, jane@example, local parts over 64 characters. This guide shows what the standard library really accepts, with results from running the code, adds a regex that rejects the rest, and finishes with the two checks that catch real bounces: an MX lookup and a mailbox check. All snippets belong to one package and were tested with Go 1.27.
1. Validate an email address in Go with a regex
This is the tested pattern from our email regex guide in its Go form:
import (
"regexp"
"strings"
)
// Go's regexp package (RE2) has no lookaheads, so the length limits are checked in code.
var emailRegex = regexp.MustCompile(
"^[A-Za-z0-9!#$%&'*+/=?^_\x60{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_\x60{|}~-]+)*" +
"@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+" +
"(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})$")
func isValidEmail(value string) bool {
at := strings.LastIndex(value, "@")
return len(value) <= 254 && at >= 1 && at <= 64 && emailRegex.MatchString(value)
}
Together with the two length checks (64 characters before the @, 254 in total), the pattern allows plus tags and apostrophes and rejects dots at the edges of the local part, double dots and domains without a top-level domain; the rules are explained in the guide to the email address format. The backtick in the character class can’t appear in a raw string, so it’s written as \x60. The anchors are safe as written: in Go, $ matches only at the end of the text unless you set the m flag, so "jane@example.com\n" fails. (.NET’s $ also matches before a final line break, which is why the C# guide uses \z.)
The pattern accepts internationalized domains only in their punycode form. Trim the input and convert the domain with golang.org/x/net/idna (go get golang.org/x/net/idna) before you call isValidEmail:
import (
"strings"
"golang.org/x/net/idna"
)
// normalizeEmail trims the input and converts an internationalized domain to punycode.
func normalizeEmail(input string) (string, error) {
email := strings.TrimSpace(input)
at := strings.LastIndex(email, "@")
if at < 0 {
return email, nil // isValidEmail rejects it
}
domain, err := idna.Lookup.ToASCII(email[at+1:])
if err != nil {
return "", err
}
return email[:at+1] + domain, nil
}
normalizeEmail(" jane@bücher returns jane@, which passes. In our test the Lookup profile also lowercased the domain and returned an error for domains with an underscore or a leading hyphen.
2. net/mail.ParseAddress: a parser, not a validator
The standard library’s address parser is mail.ParseAddress. Its documentation says it “parses a single RFC 5322 address” and gives Barry Gibbs <bg@example.com> as the example: it reads what you’d find in a From: header, display name included. To use it on form input, compare the parsed address with the input:
import (
"errors"
"net/mail"
)
var errNotBare = errors.New("enter only the email address")
// parseEmail accepts a bare address only: no display name, no angle brackets,
// no quoted local part that net/mail would silently unquote.
func parseEmail(input string) (string, error) {
addr, err := mail.ParseAddress(input)
if err != nil {
return "", err
}
if addr.Address != input {
return "", errNotBare
}
return addr.Address, nil
}
The comparison at the end matters. In our test, mail.ParseAddress returned jane@example.com for Jane Doe <jane@example.com> and for " jane@example.com ", and it turned "jane doe"@example.com into jane doe@example.com, a string that is no longer a valid address. parseEmail rejects all three. It still lets through domains such as jane@example, jane@-example.com and jane@localhost, IP addresses in brackets, non-ASCII local parts and local parts over 64 characters.
What each check accepts
Every address below went through the three checks with Go 1.27.2; Go 1.24.7 gave the same results. ✓ means the check accepts the address, ✗ that it rejects it.
| Address | Should pass? | Regex | mail.ParseAddress | parseEmail |
|---|---|---|---|---|
jane@example.com | Yes | ✓ | ✓ | ✓ |
jane.doe+news@ | Yes | ✓ | ✓ | ✓ |
o'brien@ | Yes | ✓ | ✓ | ✓ |
jane@example | No | ✗ | ✓ | ✓ |
jane@example.c | No | ✗ | ✓ | ✓ |
jane | No | ✗ | ✗ | ✗ |
.jane@ | No | ✗ | ✗ | ✗ |
jane..doe@ | No | ✗ | ✗ | ✗ |
jane@ | No | ✗ | ✗ | ✗ |
jane@-example.com | No | ✗ | ✓ | ✓ |
jane@ | No | ✗ | ✓ | ✓ |
jane doe@example.com | No | ✗ | ✗ | ✗ |
"jane doe"@example.com | No (RFC-valid) | ✗ | ✓ | ✗ |
jane@[192 | No (RFC-valid) | ✗ | ✓ | ✓ |
josé@example.com | No (needs SMTPUTF8) | ✗ | ✓ | ✓ |
jane@ | After IDN conversion | ✗ | ✓ | ✓ |
jane@ | Yes | ✓ | ✓ | ✓ |
Jane Doe <jane@example.com> | No (display name) | ✗ | ✓ | ✗ |
jane@example.com with spaces around it | After trimming | ✗ | ✓ | ✗ |
jane@example.com + line break | No | ✗ | ✗ | ✗ |
aaa…a@ (65 a’s) | No | ✗ | ✓ | ✓ |
jane@localhost | No | ✗ | ✓ | ✓ |
Only the regex rejects every row marked “No”. ParseAddress is the right tool for reading headers. For form input, run normalizeEmail and then isValidEmail: in our test that combination accepted the “Yes” rows, the IDN row and the rows with spaces or a trailing line break (which it strips), and rejected everything else.
3. MX lookup with net.LookupMX
A valid format says nothing about the domain. The standard library resolves MX records with net.LookupMX or, with a context and timeout, Resolver.LookupMX:
import (
"context"
"errors"
"net"
)
// acceptsMail reports whether a domain can receive email: it has MX records
// that are not a null MX, or no MX at all but an A/AAAA record (implicit MX).
func acceptsMail(ctx context.Context, r *net.Resolver, domain string) (bool, error) {
mxs, err := r.LookupMX(ctx, domain)
var dnsErr *net.DNSError
switch {
case err == nil:
if len(mxs) == 1 && mxs[0].Host == "." {
return false, nil // null MX (RFC 7505): the domain accepts no mail
}
return len(mxs) > 0, nil
case errors.As(err, &dnsErr) && dnsErr.IsNotFound:
// No MX records, or the domain doesn't exist: try the A/AAAA fallback.
addrs, err := r.LookupHost(ctx, domain)
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return false, nil
}
return len(addrs) > 0, err
default:
return false, err // timeout or SERVFAIL: unknown, don't reject
}
}
Call it as acceptsMail(ctx, net. Three cases need care:
- Null MX. A domain that receives no email publishes a single MX record with preference 0 and the host
.(RFC 7505). Go returns it as anMXwithHost: ".";example.comis such a domain. - No MX record. RFC 7505 notes that senders then deliver “to the hosts at the addresses in the domain’s A or AAAA records”, so the function falls back to
LookupHost. ADNSErrorwithIsNotFoundcovers both “no records of the requested type” and NXDOMAIN, which is why both paths end inLookupHost. - DNS errors. A timeout or SERVFAIL comes back as an error. Treat it as “unknown”, not as a bad address.
Against a local test DNS server, a domain with two MX records, a null MX, a domain with only an A record, one with only an AAAA record and a non-existent domain returned true, false, true, true and false; a SERVFAIL came back as an error. How MX records work is explained in the MX record guide; to check a domain by hand, use the MX lookup tool.
4. Check that the mailbox exists
The MX lookup proves that the domain takes email, not that anna@example.org exists. Only the receiving mail server knows that, and asking it from your own server is unreliable: outbound port 25 is often blocked, and catch-all domains accept every address. The email validation API runs the syntax, DNS, MX and mailbox checks in one GET request, and net/http is all you need to call it:
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
type Result struct {
Email string `json:"email"`
FormatValid bool `json:"format_valid"`
MXFound bool `json:"mx_found"`
SMTPCheck bool `json:"smtp_check"`
Disposable bool `json:"disposable"`
Role bool `json:"role"`
Free bool `json:"free"`
Score float64 `json:"score"`
State string `json:"state"`
Reason string `json:"reason"`
DidYouMean string `json:"did_you_mean"`
}
var client = &http.Client{Timeout: 10 * time.Second}
func validateEmail(ctx context.Context, email string) (*Result, error) {
endpoint := "https://api.emailvalidation.io/v1/info?email=" + url.QueryEscape(email)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("apikey", os.Getenv("EMAILVALIDATION_API_KEY"))
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("email validation API: %s", res.Status)
}
var r Result
if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
return nil, err
}
return &r, nil
}
The key comes from the EMAILVALIDATION_API_KEY environment variable; keep it out of the repository and out of any front-end code. url.QueryEscape turns the + in jane+news@example.com into %2B, so the tag reaches the API intact. A missing or wrong key comes back as 401 Unauthorized, an exhausted quota or rate limit as 429 Too Many Requests, and the function returns both as errors.
How to act on the result:
state | Typical reason | What to do |
|---|---|---|
deliverable | valid_mailbox | Accept. |
deliverable | low_ | Catch-all domain: the server accepts every address, so the mailbox can’t be confirmed. Accept, or flag it. |
undeliverable | invalid_mailbox, invalid_mx, invalid_format | Reject and ask the user to check the address. |
unknown | timeout, no_connect | The mail server gave no clear answer. Accept; don’t block a real customer. |
Check Disposable too, and show DidYouMean when it isn’t empty: for anna@gmial.com it suggests anna@gmail.com. Catch-all email explains why those domains can’t be verified per mailbox. The free plan includes 100 checks a month, and only successful calls count.
5. Validate email in a Go HTTP handler
In the handler, the format check runs first and answers at once; the API check runs with its own timeout and fails open, so an API outage never blocks a sign-up:
import (
"context"
"log"
"net/http"
"time"
)
func signupHandler(w http.ResponseWriter, r *http.Request) {
email, err := normalizeEmail(r.FormValue("email"))
if err != nil || !isValidEmail(email) {
http.Error(w, "Please enter a valid email address.", http.StatusUnprocessableEntity)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
result, err := validateEmail(ctx, email)
switch {
case err != nil:
log.Printf("email check skipped: %v", err) // API down or slow: don't block the sign-up
case result.State == "undeliverable":
http.Error(w, "This email address doesn't exist. Please check it.", http.StatusUnprocessableEntity)
return
case result.Disposable:
http.Error(w, "Please use a permanent email address.", http.StatusUnprocessableEntity)
return
}
// create the account with email ...
w.WriteHeader(http.StatusCreated)
}
func main() {
http.HandleFunc("POST /signup", signupHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The "POST /signup" pattern needs Go 1.22 or later; the net/http ServeMux then answers other methods with 405. We built the program, pointed it at a local mock of the API and posted form data to /signup:
Posted email | Response |
|---|---|
anna@example.org | 201 |
anna@example.org with spaces around it | 201 (trimmed) |
jane@ | 201 (sent to the API as jane@) |
jane@example | 422, “Please enter a valid email address.” |
An address the API reports as undeliverable | 422, “This email address doesn’t exist. Please check it.” |
An address the API reports as disposable | 422, “Please use a permanent email address.” |
| Any address while the API answers 429 | 201 (check skipped, error logged) |
Recommended setup
- In the browser:
type="email"for instant feedback (the server still decides). - On the server:
normalizeEmail, thenisValidEmail. - On submit: the API check. Block
undeliverableand disposable addresses, accept the rest, and accept everything when the API is unreachable. - For an existing list, check the whole file at once with the bulk email verifier instead of one request per row.
To see the API’s answer for one address before you write code, paste it into the free email validator. The same checks in other languages: C# email validation and Java email validation.
Sources
Checked October 10, 2026: net/mail, net (LookupMX, DNSError), net/http ServeMux and regexp/syntax (Go 1.27.2); golang.org/x/net/idna; RFC 7505 (null MX). Code tested with Go 1.27.2 (net/mail and MX results also on Go 1.24.7) and golang.org/x/net v0.61.0 against a local DNS server and a local mock of the API.