On this page
To validate a phone number in Go, parse it with phonenumbers.Parse from github.com/nyaruka/phonenumbers/v2, check phonenumbers.IsValidNumber, and store phonenumbers.Format(num, phonenumbers.E164). That checks the numbering plan. To learn the line type or whether the number is in service, batch valid numbers into a lookup API, with a context deadline, client-side rate limiting and Retry-After back-off. Everything below was run with Go 1.27.1.
Which Go port of libphonenumber should you use?
github.com/nyaruka/phonenumbers is the maintained choice. It's a port of Google's libphonenumber under the MIT licence. Version 2 (June 2026) made it a stricter port of Google's Java API and moved the module to the /v2 import path; the latest release on 2026-09-25 was v2.0.12. According to its upgrade guide, parsing, validation and formatting give the same results as in 1.x, and the code below compiles and passes its tests unchanged with both v2.0.12 and v1.8.1, apart from the import path.
One thing to know: the Go port uses its own version numbers and regenerates metadata on its own schedule. Its changelog says v1.8.0 (2026-06-01) was built against libphonenumber 9.0.32, while Google had released 9.0.40 by 2026-09-23; later releases just say "Update metadata". Google shipped 25 metadata releases in 2025, so a lag of a few releases is normal. It rarely matters for mainstream ranges, but it can for a country that changed its plan last month. If you validate on both sides, the API's answer should win.
go get github.com/nyaruka/phonenumbers/v2@v2.0.12 golang.org/x/time/rate golang.org/x/sync/errgroupA regex won't do this job. See E.164 regex: why a pattern is not enough for counterexamples.
How do you parse, validate and format a number?
// phone/phone.go
package phone
import (
"regexp"
"strings"
"github.com/nyaruka/phonenumbers/v2"
)
// Our reserved test range (+44 7700 9xxxxx). libphonenumber marks it invalid on purpose.
var testRange = regexp.MustCompile(`^\+4477009\d{5}$`)
type Result struct {
OK bool
E164 string
Region string
Type phonenumbers.PhoneNumberType
Reason string // unparseable | invalid | too_short | too_long
}
// Normalize parses raw input and returns the E.164 form if the number is valid.
func Normalize(raw, defaultRegion string, allowTestRange bool) Result {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 32 {
return Result{Reason: "unparseable"}
}
num, err := phonenumbers.Parse(raw, defaultRegion) // defaultRegion like "GB"; "" requires a leading +
if err != nil {
return Result{Reason: "unparseable"}
}
e164 := phonenumbers.Format(num, phonenumbers.E164)
if allowTestRange && testRange.MatchString(e164) {
return Result{OK: true, E164: e164, Region: "GB"}
}
if !phonenumbers.IsValidNumber(num) {
switch phonenumbers.IsPossibleNumberWithReason(num) {
case phonenumbers.TOO_SHORT:
return Result{Reason: "too_short"}
case phonenumbers.TOO_LONG:
return Result{Reason: "too_long"}
}
return Result{Reason: "invalid"}
}
return Result{OK: true, E164: e164, Region: phonenumbers.GetRegionCodeForNumber(num), Type: phonenumbers.GetNumberType(num)}
}A table-driven test pins the behaviour. It passes with v2.0.12:
// phone/phone_test.go
package phone
import (
"testing"
"github.com/nyaruka/phonenumbers/v2"
)
func TestNormalize(t *testing.T) {
cases := []struct{ in, region, e164, reason string }{
{"(202) 555-0143", "US", "+12025550143", ""},
{"07911 123456", "GB", "+447911123456", ""},
{"+49 1512 3456789", "", "+4915123456789", ""},
{"+1 123 456 7890", "", "", "invalid"}, // right length, impossible area code
{"+44 79111 234567", "", "", "too_long"},
{"202-555-0143", "", "", "unparseable"}, // no region, no +
{"07700 900001", "GB", "", "invalid"}, // reserved drama range
}
for _, c := range cases {
r := Normalize(c.in, c.region, false)
if r.E164 != c.e164 || r.Reason != c.reason {
t.Errorf("Normalize(%q, %q) = %+v", c.in, c.region, r)
}
}
if r := Normalize("+44 7911 123456", "", false); r.Region != "GG" || r.Type != phonenumbers.MOBILE {
t.Errorf("got %+v", r)
}
}The GG case is deliberate: +44 7911 numbers belong to Guernsey. Always take the region from GetRegionCodeForNumber, never from the dialling code. IsPossibleNumberWithReason only checks length, which is useful for friendlier error messages; IsValidNumber also checks that the digits fall in an allocated range.
How do you call a lookup API from Go?
Validation says a number could exist. A carrier lookup tells you what it is today: line_type (mobile, fixed line, VoIP…) and the current carrier. The client below talks to the MobileValidate API. It:
- sends numbers in the POST body, never in the URL;
- uses
http.NewRequestWithContext, so the caller's deadline cancels everything; - paces itself with a token bucket, below the API's limit of 10 requests per second per key;
- retries only errors the API marks
retryable, honouringRetry-After; - polls
GET /v1/lookups/{id}while answers are pending.
// mv/client.go
package mv
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"golang.org/x/time/rate"
)
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
Limiter *rate.Limiter // client-side pacing: stay under 10 requests/s per key
}
func New(apiKey, baseURL string) *Client {
if baseURL == "" {
baseURL = "https://api.mobilevalidate.com"
}
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTP: &http.Client{Timeout: 45 * time.Second}, // the server may long-poll for up to `wait` seconds
Limiter: rate.NewLimiter(rate.Limit(8), 8),
}
}
type CheckResult struct {
Status string `json:"status"` // completed | unknown | pending | unsupported_country
Registered *bool `json:"registered"` // nil = unknown (not false)
Attributes map[string]any `json:"attributes"`
Reason *string `json:"reason"`
CheckedAt *string `json:"checked_at"`
}
type Result struct {
Input string `json:"input"`
E164 *string `json:"e164"`
NumberStatus string `json:"number_status"` // valid | invalid_number | duplicate | suppressed
Checks map[string]CheckResult `json:"checks"`
}
type Lookup struct {
ID string `json:"id"`
Status string `json:"status"` // completed | pending
Results []Result `json:"results"`
Next *struct {
PollAfterMs int `json:"poll_after_ms"`
} `json:"next"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
Retryable bool `json:"retryable"`
}
func (e *APIError) Error() string { return fmt.Sprintf("%s (%d): %s", e.Code, e.Status, e.Message) }
// Lookup checks up to 100 numbers. Numbers go in the POST body, never in the URL.
func (c *Client) Lookup(ctx context.Context, numbers, checks []string) (*Lookup, error) {
body, _ := json.Marshal(map[string]any{"numbers": numbers, "checks": checks, "wait": 10})
var l Lookup
if err := c.do(ctx, http.MethodPost, "/v1/lookup", body, &l); err != nil {
return nil, err
}
for l.Status == "pending" {
wait := 2 * time.Second
if l.Next != nil && l.Next.PollAfterMs > 0 {
wait = time.Duration(l.Next.PollAfterMs) * time.Millisecond
}
select {
case <-ctx.Done():
return &l, ctx.Err() // partial results; pending checks can be fetched later by id
case <-time.After(wait):
}
if err := c.do(ctx, http.MethodGet, "/v1/lookups/"+l.ID+"?wait=10", nil, &l); err != nil {
return nil, err
}
}
return &l, nil
}
func (c *Client) do(ctx context.Context, method, path string, body []byte, out any) error {
for attempt := 0; ; attempt++ {
if err := c.Limiter.Wait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
if res.StatusCode < 300 {
defer res.Body.Close()
return json.NewDecoder(res.Body).Decode(out)
}
var e struct{ Error APIError }
_ = json.NewDecoder(res.Body).Decode(&e)
res.Body.Close()
if !e.Error.Retryable || attempt == 3 {
return &e.Error
}
delay := time.Duration(1<<attempt) * time.Second
if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(s) * time.Second
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
}Registered and Reason are pointers on purpose. JSON null means "unknown", and decoding it into a plain bool would silently turn unknown into false.
How do you check a large list concurrently without hitting 429?
Batch first, parallelize second. One lookup takes up to 100 numbers, so 10,000 numbers are 100 requests, not 10,000. errgroup.SetLimit caps the requests in flight, and the shared limiter caps the rate:
// cmd/demo/main.go (excerpt)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(4)
var mu sync.Mutex
var results []mv.Result
for start := 0; start < len(numbers); start += 100 {
batch := numbers[start:min(start+100, len(numbers))]
g.Go(func() error {
l, err := client.Lookup(ctx, batch, []string{"carrier"})
if err != nil {
return err
}
mu.Lock()
results = append(results, l.Results...)
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
var apiErr *mv.APIError
if errors.As(err, &apiErr) {
log.Printf("API error: %s", apiErr.Code) // log codes, never phone numbers
}
return err
}batch is declared inside the loop body, so each goroutine gets its own slice. In our test runs, 25 lookups of 100 numbers each finished in a few seconds, paced by the limiter at 8 requests per second, with no 429s. For lists in the tens of thousands, a bulk job is the better tool: one request for up to 50,000 numbers, at the lower bulk price.
Keep the rules on the rate limits page in mind. Twenty or more consecutive numbers in one request are refused as suspected_enumeration, so check the numbers you hold, not generated ranges.
What does the output look like with a test key?
The demo normalizes five inputs, drops the invalid one and checks the rest with a free test key (MOBILEVALIDATE_API_KEY=mv_test_…). The test numbers return fixed answers and are never billed. Real output:
skip "+44 1234": invalid
+447700900001 completed mobile
+447700900002 unknown unknown NO_DATA
+447700900003 unknown unknown UPSTREAM_TIMEOUT
+447700900004 completed mobile…004 was pending for about five seconds, so the client polled once. With +447700900429 the API answers 429 rate_limited with Retry-After: 1. Our client retried three times and returned rate_limited (429) after about three seconds. With …402 it returns insufficient_balance at once, because that error isn't retryable.
The demo passes allowTestRange only when the key starts with mv_test_. libphonenumber treats +44 7700 900xxx as invalid because the UK regulator Ofcom reserves it for drama, and our test mode uses that range for the same reason.
How should your service act on the answers?
mobile: SMS is a reasonable channel. Store the line type withchecked_at.fixed_line,toll_free,voip: skip SMS; offer voice or e-mail, or add a verification step if your risk rules need it.unknownstatus: keep your default and re-check later. Unknown answers aren't billed. Leave the pointernil; don't storefalse.invalid_number/duplicate: the API normalizes and deduplicates before checking, and neither is charged.
In request paths such as sign-up, give the lookup a short deadline with context.WithTimeout and fail open: a slow dependency shouldn't block a real user. In batch jobs, use a longer deadline and let the retry logic work.
What are the key takeaways?
- Use
github.com/nyaruka/phonenumbers/v2andIsValidNumber; format withphonenumbers.E164and take the region from the parser. - The Go port's metadata can trail Google's by a few releases. Check its changelog when you upgrade.
- Decode nullable API fields into pointers, so unknown never becomes
false. - Pace requests with
x/time/rate, cap concurrency witherrgroup.SetLimit, and retry only retryable errors afterRetry-After. - Batch up to 100 numbers per lookup; use a bulk job for large lists.
- Test every branch for free with the test numbers, including the
429path.
Sources
- nyaruka/phonenumbers — GitHub (Nyaruka), 2026
- phonenumbers package documentation — pkg.go.dev, 2026
- rate package (golang.org/x/time/rate) — pkg.go.dev, 2026
- libphonenumber release notes — Google, 2026
- RFC 9110: HTTP Semantics (Retry-After) — IETF, 2022
Frequently asked questions
Which Go library should I use for phone number validation?
github.com/nyaruka/phonenumbers/v2, a port of Google's libphonenumber (MIT licence). It parses any common format, validates against each country's numbering plan and formats to E.164.
Is nyaruka/phonenumbers up to date with Google's metadata?
It can lag a little. The port regenerates its metadata on its own schedule: its changelog says v1.8.0 (2026-06-01) was built against libphonenumber 9.0.32, while Google had released 9.0.40 by 2026-09-23. Check the changelog before you upgrade, and don't treat edge cases in recently changed countries as final.
How should a Go client handle 429 responses?
Wait for the number of seconds in the Retry-After header, then retry, and only for errors the API marks as retryable. Pace requests on the client with a token bucket such as golang.org/x/time/rate, so you rarely see 429 at all.
How many numbers can I check per request?
Up to 100 per real-time lookup and up to 50,000 per bulk job. The request rate is 10 per second per key with bursts of 20, so batching is more effective than parallel single-number calls.
Related services and guides
More from the blog
All articlesDevelopers
Phone number validation in JavaScript and Node.js
Validate phone numbers in JavaScript with libphonenumber-js: min vs max metadata, E.164, form input, and a live line-type check with runnable Node code.
9 min read
Developers
E.164 regex: why a pattern is not enough to validate phone numbers
The common E.164 regex accepts impossible numbers. Real counterexamples, the shortest valid international numbers, and what to use a regex for instead.
7 min read
Developers
Phone number validation in PHP and Laravel
Validate phone numbers in PHP with libphonenumber-for-php, write a Laravel rule, and add a live line-type check with Guzzle that never breaks the form.
7 min read

