Tutorial · Go

Golang validate email addresses: from regex to mailbox check

Four checks with working Go code: a format regex, the standard library’s address parser and its surprises, an MX lookup, and a mailbox check inside a net/http handler.

Updated October 10, 2026 · 10 min read

Go email validation: parseEmail wraps net/mail.ParseAddress and rejects anything that isn’t a bare address, such as "jane doe"@example.com

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.example.com ") returns jane@xn--bcher-kva.example.com, 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.

AddressShould pass?Regexmail.ParseAddressparseEmail
jane@example.comYes✓✓✓
jane.doe+news@example.comYes✓✓✓
o'brien@example.ieYes✓✓✓
jane@exampleNo✗✓✓
jane@example.cNo✗✓✓
jane.@example.comNo✗✗✗
.jane@example.comNo✗✗✗
jane..doe@example.comNo✗✗✗
jane@example.com.No✗✗✗
jane@-example.comNo✗✓✓
jane@exa_mple.comNo✗✓✓
jane doe@example.comNo✗✗✗
"jane doe"@example.comNo (RFC-valid)✗✓✗
jane@[192.0.2.10]No (RFC-valid)✗✓✓
josé@example.comNo (needs SMTPUTF8)✗✓✓
jane@bücher.example.comAfter IDN conversion✗✓✓
jane@xn--bcher-kva.example.comYes✓✓✓
Jane Doe <jane@example.com>No (display name)✗✓✗
jane@example.com with spaces around itAfter trimming✗✓✗
jane@example.com + line breakNo✗✗✗
aaa…a@example.com (65 a’s)No✗✓✓
jane@localhostNo✗✓✓

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.DefaultResolver, "example.org"). Three cases need care:

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:

stateTypical reasonWhat to do
deliverablevalid_mailboxAccept.
deliverablelow_deliverabilityCatch-all domain: the server accepts every address, so the mailbox can’t be confirmed. Accept, or flag it.
undeliverableinvalid_mailbox, invalid_mx, invalid_formatReject and ask the user to check the address.
unknowntimeout, no_connectThe 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 emailResponse
anna@example.org201
anna@example.org with spaces around it201 (trimmed)
jane@bücher.example.com201 (sent to the API as jane@xn--bcher-kva.example.com)
jane@example422, “Please enter a valid email address.”
An address the API reports as undeliverable422, “This email address doesn’t exist. Please check it.”
An address the API reports as disposable422, “Please use a permanent email address.”
Any address while the API answers 429201 (check skipped, error logged)
  1. In the browser: type="email" for instant feedback (the server still decides).
  2. On the server: normalizeEmail, then isValidEmail.
  3. On submit: the API check. Block undeliverable and disposable addresses, accept the rest, and accept everything when the API is unreachable.
  4. 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.

Validate email in other languages

Frequently asked questions

How do I validate an email address in Go?

Trim the input with strings.TrimSpace, check the format with a tested regex plus the two length limits, and for sign-ups or imports check the mailbox on the server with an email validation API. mail.ParseAddress alone accepts addresses such as jane@example and Jane Doe <jane@example.com>.

Does net/mail.ParseAddress validate email addresses?

It parses an RFC 5322 address, which is not the same thing: it accepts display names, domains without a dot, underscores and leading hyphens in domains, and local parts over 64 characters, and it removes the quotes from "jane doe"@example.com. Use it to read headers, not to accept form input.

How do I check MX records in Go?

Call net.LookupMX or Resolver.LookupMX with a context. Treat a single record with the host . as a null MX (no mail), fall back to LookupHost when there is no MX record, and don’t reject an address because of a timeout or SERVFAIL.

Can Go check if an email address exists?

Not reliably by itself. Talking SMTP to the receiving server needs outbound port 25, which many hosts block, and catch-all domains accept every address. An email validation API runs the DNS, MX and mailbox checks and returns one result per address.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "valid_mailbox",
  "did_you_mean": ""
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free