# Phone number validation in Go: parsing, E.164 and rate-limited lookups

> Validate phone numbers in Go with nyaruka/phonenumbers, format E.164, and call a lookup API with context timeouts, rate limiting and 429 back-off.

Canonical: https://mobilevalidate.com/blog/phone-number-validation-go · Last updated: 2026-10-01

![A Go code panel fans out to five parallel lookup lanes, one flagged; a token bucket limits the rate and a step curve shows 429 back-off.](https://mobilevalidate.com/images/blog/phone-number-validation-go.svg)

*Validate locally, then call the API concurrently within the rate limit.*


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-10-01 · Category: Developers · Tags: Phone validation, Golang, Libphonenumber, E.164, Rate limits, API

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`](https://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.

```bash
go get github.com/nyaruka/phonenumbers/v2@v2.0.12 golang.org/x/time/rate golang.org/x/sync/errgroup
```

A regex won't do this job. See [E.164 regex: why a pattern is not enough](/blog/e164-regex-is-not-enough) for counterexamples.

## How do you parse, validate and format a number?

```go
// 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:

```go
// 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](/services/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`, honouring `Retry-After`;
- polls `GET /v1/lookups/{id}` while answers are pending.

```go
// 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:

```go
// 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](/docs/bulk-jobs) 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](/docs/rate-limits-and-abuse) 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](/docs/test-mode) return fixed answers and are never billed. Real output:

```text
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 with `checked_at`.
- **`fixed_line`, `toll_free`, `voip`:** skip SMS; offer voice or e-mail, or add a verification step if your risk rules need it.
- **`unknown` status:** keep your default and re-check later. Unknown answers aren't billed. Leave the pointer `nil`; don't store `false`.
- **`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/v2` and `IsValidNumber`; format with `phonenumbers.E164` and 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 with `errgroup.SetLimit`, and retry only retryable errors after `Retry-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 `429` path.

## Sources

1. [nyaruka/phonenumbers](https://github.com/nyaruka/phonenumbers) — GitHub (Nyaruka), 2026
2. [phonenumbers package documentation](https://pkg.go.dev/github.com/nyaruka/phonenumbers/v2) — pkg.go.dev, 2026
3. [rate package (golang.org/x/time/rate)](https://pkg.go.dev/golang.org/x/time/rate) — pkg.go.dev, 2026
4. [libphonenumber release notes](https://github.com/google/libphonenumber/blob/master/release_notes.txt) — Google, 2026
5. [RFC 9110: HTTP Semantics (Retry-After)](https://www.rfc-editor.org/rfc/rfc9110) — 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.
