Skip to content

Validate phone numbers in Go

One file that reads a number the way people type it, checks it against the numbering plans you pick, names its line type, writes it as E.164 and as a tel: link, and says what to dial from another country. The patterns are the plans' own. The tests come with it, and the code on this page was run before it was published.

Files
phone.go, tests in phone_test.go
Needs
Go 1.21 or later; standard library only
Tests
testing
Plans as of
2026-10-25

What it answers

CallWith the United Kingdom's plan loaded
Parse “07400 123456”, default region GB+447400123456, GB, mobile
Teltel:+447400123456
DialFrom from US011447400123456
Parse “07400 12345”ok == false: no range of the plan has that length

The plans in this file

Add the countries whose numbers you accept; the file and its tests below are generated again for them, in your browser.

  • United Kingdom +44
  • United States +1
  • France +33

1. The plans

Plans holds one entry per plan: its region (the ISO code), the country code, the trunk prefix dialled before a national number, the exit code dialled before an international one, and a pattern per line type. The patterns are the plans' own, anchored, in the order the file tries them: the special ranges first (premium rate, toll-free, shared cost, VoIP, personal, pager, UAN, voicemail), then fixed line, then mobile. A plan that names no single exit code gets an empty one, and calls out of it use the + form.

// Package phone validates, normalises and dials phone numbers with the national numbering plans.
// Plans: GB +44, US +1, FR +33 (data as of 2026-10-25).
// Other plans: https://phonelookup.dev/guides/validate-phone-numbers-go?plans=gb,us,fr
// Each pattern matches a national significant number: digits only, no country code, no trunk prefix.
package phone

import (
	"regexp"
	"slices"
	"strings"
)

// Plan is one numbering plan: region, country code, trunk prefix, exit code and a pattern per line type.
type Plan struct {
	Region, CC, Trunk, Exit string
	Types                   []LineType
}

// LineType is a line type's name and its anchored pattern.
type LineType struct {
	Name    string
	Pattern *regexp.Regexp
}

// Number is a number as read: E.164, the plan it belongs to, its line type, country code and national number.
type Number struct {
	E164, Region, Type, CC, NSN string
}

func lineType(name, pattern string) LineType {
	return LineType{name, regexp.MustCompile(`^(?:` + pattern + `)$`)}
}

// Plans holds the numbering plans the package reads.
var Plans = []Plan{
	{Region: "GB", CC: "44", Trunk: "0", Exit: "00", Types: []LineType{
		lineType("premiumRate", `(?:8(?:4[2-5]|7[0-3])|9(?:[01]\d|8[2-49]))\d{7}|845464\d`),
		lineType("tollFree", `80[08]\d{7}|800\d{6}|8001111`),
		lineType("voip", `56\d{8}`),
		lineType("personalNumber", `70\d{8}`),
		lineType("pager", `76(?:464|652)\d{5}|76(?:0[0-28]|2[356]|34|4[01347]|5[49]|6[0-369]|77|8[14]|9[139])\d{6}`),
		lineType("uan", `(?:3[0347]|55)\d{8}`),
		lineType("fixedLine", `(?:1(?:1(?:3(?:[0-58]\d\d|73[0-5])|4(?:(?:[0-5]\d|70)\d|69[7-9])|(?:(?:5[0-26-9]|[78][0-49])\d|6(?:[0-4]\d|5[01]))\d)|(?:2(?:(?:0[024-9]|2[3-9]|3[3-79]|4[1-689]|[58][02-9]|6[0-47-9]|7[013-9]|9\d)\d|1(?:[0-7]\d|8[0-3]))|(?:3(?:0\d|1[0-8]|[25][02-9]|3[02-579]|[468][0-46-9]|7[1-35-79]|9[2-578])|4(?:0[03-9]|[137]\d|[28][02-57-9]|4[02-69]|5[0-8]|[69][0-79])|5(?:0[1-35-9]|[16]\d|2[024-9]|3[015689]|4[02-9]|5[03-9]|7[0-35-9]|8[0-468]|9[0-57-9])|6(?:0[034689]|1\d|2[0-35689]|[38][013-9]|4[1-467]|5[0-69]|6[13-9]|7[0-8]|9[0-24578])|7(?:0[0246-9]|2\d|3[0236-8]|4[03-9]|5[0-46-9]|6[013-9]|7[0-35-9]|8[024-9]|9[02-9])|8(?:0[35-9]|2[1-57-9]|3[02-578]|4[0-578]|5[124-9]|6[2-69]|7\d|8[02-9]|9[02569])|9(?:0[02-589]|[18]\d|2[02-689]|3[1-57-9]|4[2-9]|5[0-579]|6[2-47-9]|7[0-24578]|9[2-57]))\d)\d)|2(?:0[013478]|3[0189]|4[017]|8[0-46-9]|9[0-2])\d{3})\d{4}|1(?:2(?:0(?:46[1-4]|87[2-9])|545[1-79]|76(?:2\d|3[1-8]|6[1-6])|9(?:7(?:2[0-4]|3[2-5])|8(?:2[2-8]|7[0-47-9]|8[3-5])))|3(?:6(?:38[2-5]|47[23])|8(?:47[04-9]|64[0157-9]))|4(?:044[1-7]|20(?:2[23]|8\d)|6(?:0(?:30|5[2-57]|6[1-8]|7[2-8])|140)|8(?:052|87[1-3]))|5(?:2(?:4(?:3[2-79]|6\d)|76\d)|6(?:26[06-9]|686))|6(?:06(?:4\d|7[4-79])|295[5-7]|35[34]\d|47(?:24|61)|59(?:5[08]|6[67]|74)|9(?:55[0-4]|77[23]))|7(?:26(?:6[13-9]|7[0-7])|(?:442|688)\d|50(?:2[0-3]|[3-68]2|76))|8(?:27[56]\d|37(?:5[2-5]|8[239])|843[2-58])|9(?:0(?:0(?:6[1-8]|85)|52\d)|3583|4(?:66[1-8]|9(?:2[01]|81))|63(?:23|3[1-4])|9561))\d{3}`),
		lineType("mobile", `7(?:457[0-57-9]|700[01]|911[028])\d{5}|7(?:[1-3]\d\d|4(?:[0-46-9]\d|5[0-689])|5(?:0[0-8]|[13-9]\d|2[0-35-9])|7(?:0[1-9]|[1-7]\d|8[02-9]|9[0-689])|8(?:[014-9]\d|[23][0-8])|9(?:[024-9]\d|1[02-9]|3[0-689]))\d{6}`),
	}},
	{Region: "US", CC: "1", Trunk: "1", Exit: "011", Types: []LineType{
		lineType("premiumRate", `900[2-9]\d{6}`),
		lineType("tollFree", `8(?:00|33|44|55|66|77|88)[2-9]\d{6}`),
		lineType("personalNumber", `52(?:3(?:[2-46-9][02-9]\d|5(?:[02-46-9]\d|5[0-46-9]))|4(?:[2-478][02-9]\d|5(?:[034]\d|2[024-9]|5[0-46-9])|6(?:0[1-9]|[2-9]\d)|9(?:[05-9]\d|2[0-5]|49)))\d{4}|52[34][2-9]1[02-9]\d{4}|5(?:00|2[125-9]|3[23]|44|66|77|88)[2-9]\d{6}`),
		lineType("fixedLine", `983[2-57-9]\d{6}|(?:2(?:0[1-35-9]|1[02-9]|2[03-57-9]|3[1459]|4[08]|5[1-46]|6[0279]|7[02469]|8[13])|3(?:0[1-57-9]|1[02-9]|2[013-79]|3[0-24679]|4[167]|5[0-3]|6[01349]|8[056])|4(?:0[124-9]|1[02-579]|2[3-5]|3[0245]|4[023578]|58|6[349]|7[02589]|8[04])|5(?:0[1-57-9]|1[0235-8]|20|3[0149]|4[01]|5[179]|6[1-47]|7[0-5]|8[0256])|6(?:0[1-35-9]|1[024-9]|2[03689]|3[016]|4[0156]|5[01679]|6[0-279]|78|8[0-269])|7(?:0[1-46-8]|1[2-9]|2[04-8]|3[0-2478]|4[0378]|5[47]|6[02359]|7[0-59]|8[156])|8(?:0[1-68]|1[02-8]|2[0168]|3[0-2589]|4[03578]|5[046-9]|6[02-5]|7[028])|9(?:0[1346-9]|1[02-9]|2[0589]|3[0146-8]|4[01357-9]|5[12469]|7[0-3589]|8[04-69]))[2-9]\d{6}`),
		lineType("mobile", `983[2-57-9]\d{6}|(?:2(?:0[1-35-9]|1[02-9]|2[03-57-9]|3[1459]|4[08]|5[1-46]|6[0279]|7[02469]|8[13])|3(?:0[1-57-9]|1[02-9]|2[013-79]|3[0-24679]|4[167]|5[0-3]|6[01349]|8[056])|4(?:0[124-9]|1[02-579]|2[3-5]|3[0245]|4[023578]|58|6[349]|7[02589]|8[04])|5(?:0[1-57-9]|1[0235-8]|20|3[0149]|4[01]|5[179]|6[1-47]|7[0-5]|8[0256])|6(?:0[1-35-9]|1[024-9]|2[03689]|3[016]|4[0156]|5[01679]|6[0-279]|78|8[0-269])|7(?:0[1-46-8]|1[2-9]|2[04-8]|3[0-2478]|4[0378]|5[47]|6[02359]|7[0-59]|8[156])|8(?:0[1-68]|1[02-8]|2[0168]|3[0-2589]|4[03578]|5[046-9]|6[02-5]|7[028])|9(?:0[1346-9]|1[02-9]|2[0589]|3[0146-8]|4[01357-9]|5[12469]|7[0-3589]|8[04-69]))[2-9]\d{6}`),
	}},
	{Region: "FR", CC: "33", Trunk: "0", Exit: "00", Types: []LineType{
		lineType("premiumRate", `836(?:0[0-36-9]|[1-9]\d)\d{4}|8(?:1[2-9]|2[2-47-9]|3[0-57-9]|[569]\d|8[0-35-9])\d{6}`),
		lineType("tollFree", `80[0-5]\d{6}`),
		lineType("sharedCost", `8(?:1[01]|2[0156]|4[024]|84)\d{6}`),
		lineType("voip", `9\d{8}`),
		lineType("uan", `80[6-9]\d{6}`),
		lineType("fixedLine", `(?:26[013-9]|59[1-35-9])\d{6}|(?:[13]\d|2[0-57-9]|4[1-9]|5[0-8])\d{7}`),
		lineType("mobile", `(?:6(?:[0-24-8]\d|3[0-8]|9[589])|7[3-9]\d)\d{6}`),
	}},
}

2. Reading a number

Parse keeps the digits and a leading plus. With the plus, or with the default region's exit code in front (00 from the United Kingdom, 011 from the United States), the first one to three digits are the country code: under ITU-T E.164 no code is the prefix of another, so at most one length names a plan, and the plans sharing a code (+1, +7, +44) are tried in turn. Without either, the number is national and is read in the default region's plan. The trunk prefix is tried stripped first, so “+44 (0)7400 123456” reads too. A number in both the fixed-line and the mobile ranges comes back as fixedLineOrMobile, which is every ordinary number in the +1 plans. Digits typed in other scripts are dropped with the punctuation; convert them first if your users type them. Anything no plan accepts gives ok == false.

var notDialled = regexp.MustCompile(`[^0-9+]`)

func planFor(region string) *Plan {
	for i := range Plans {
		if Plans[i].Region == region {
			return &Plans[i]
		}
	}
	return nil
}

// Parse reads a number the way people type it: "+44 7400 123456", "07400 123456", "00 44 7400 123456".
// A number without + or an exit code is read in the plan of defaultRegion (an ISO code such as "GB", or "").
// ok is false when no plan in Plans accepts it.
func Parse(text, defaultRegion string) (n Number, ok bool) {
	s := notDialled.ReplaceAllString(text, "")
	digits := strings.ReplaceAll(s, "+", "")
	if len(digits) < 3 || len(digits) > 17 {
		return Number{}, false
	}
	if strings.HasPrefix(s, "+") {
		return international(digits)
	}
	home := planFor(defaultRegion)
	if home == nil {
		return Number{}, false
	}
	if home.Exit != "" && strings.HasPrefix(digits, home.Exit) {
		return international(digits[len(home.Exit):])
	}
	plans := []*Plan{home}
	for i := range Plans {
		if Plans[i].CC == home.CC && &Plans[i] != home {
			plans = append(plans, &Plans[i])
		}
	}
	return match(plans, digits)
}

// Country codes are prefix-free (ITU-T E.164): the first one to three digits that name a plan are the code.
func international(digits string) (Number, bool) {
	for size := 1; size <= min(3, len(digits)); size++ {
		var plans []*Plan
		for i := range Plans {
			if Plans[i].CC == digits[:size] {
				plans = append(plans, &Plans[i])
			}
		}
		if len(plans) > 0 {
			return match(plans, digits[size:])
		}
	}
	return Number{}, false
}

// The trunk prefix is tried stripped first: "+44 (0)7400 123456" is common.
func match(plans []*Plan, nsn string) (Number, bool) {
	for _, p := range plans {
		tries := []string{nsn}
		if p.Trunk != "" && strings.HasPrefix(nsn, p.Trunk) {
			tries = []string{nsn[len(p.Trunk):], nsn}
		}
		for _, national := range tries {
			if t := typeOf(p, national); t != "" {
				return Number{E164: "+" + p.CC + national, Region: p.Region, Type: t, CC: p.CC, NSN: national}, true
			}
		}
	}
	return Number{}, false
}

// The first matching type wins; a number in both the fixed-line and the mobile ranges is reported as such.
func typeOf(p *Plan, nsn string) string {
	var hits []string
	for _, t := range p.Types {
		if t.Pattern.MatchString(nsn) {
			hits = append(hits, t.Name)
		}
	}
	if len(hits) == 0 {
		return ""
	}
	if hits[0] == "fixedLine" && slices.Contains(hits, "mobile") {
		return "fixedLineOrMobile"
	}
	return hits[0]
}

3. Writing and dialling it

Store the E.164 form, a plus and digits only: one string per number, and every other format follows from it.Tel writes the tel: URI for links (RFC 3966). DialFrom gives the digits a person dials from another country: the caller's exit code, the country code and the national number, or, between plans that share a country code, a national call with the trunk prefix. The dialling helper explains each case.

// Tel returns the tel: URI (RFC 3966), for links.
func (n Number) Tel() string { return "tel:" + n.E164 }

// DialFrom returns what to dial from a phone in fromRegion. Inside one country code (the UK and Jersey,
// the US and Canada) the call is dialled like a national one, with the trunk prefix; otherwise the exit
// code, the country code and the national number. The + form when fromRegion is not in Plans or its plan
// names no exit code.
func (n Number) DialFrom(fromRegion string) string {
	from := planFor(fromRegion)
	switch {
	case from == nil:
		return n.E164
	case from.CC == n.CC:
		return from.Trunk + n.NSN
	case from.Exit != "":
		return from.Exit + n.CC + n.NSN
	default:
		return n.E164
	}
}

The tests

The cases are the plans' own example numbers, one per line type in international form; the first plan's mobile number again in national form, with “(0)”, and dialled with an exit code; then numbers no plan accepts, and three calls dialled. Their expected values are computed when the page is built, by the same logic in TypeScript. The generator behind this page is checked by running its output in testing, for these plans and for all 245 plans at once.

go mod init example.com/phone
go test
package phone

import "testing"

func TestParseEachLineTypeOfEachPlan(t *testing.T) {
	cases := []struct{ text, region, e164, typ string }{
		// typed, default region, E.164, line type
		{"+44 7400 123456", "", "+447400123456", "mobile"},
		{"+44 121 234 5678", "", "+441212345678", "fixedLine"},
		{"+44 800 123 4567", "", "+448001234567", "tollFree"},
		{"+44 56 1234 5678", "", "+445612345678", "voip"},
		{"+44 901 234 5678", "", "+449012345678", "premiumRate"},
		{"+44 70 1234 5678", "", "+447012345678", "personalNumber"},
		{"+44 76 4012 3456", "", "+447640123456", "pager"},
		{"+44 55 1234 5678", "", "+445512345678", "uan"},
		{"+1 201-555-0123", "", "+12015550123", "fixedLineOrMobile"},
		{"+1 800-234-5678", "", "+18002345678", "tollFree"},
		{"+1 900-234-5678", "", "+19002345678", "premiumRate"},
		{"+1 500-234-5678", "", "+15002345678", "personalNumber"},
		{"+33 6 12 34 56 78", "", "+33612345678", "mobile"},
		{"+33 1 23 45 67 89", "", "+33123456789", "fixedLine"},
		{"+33 801 23 45 67", "", "+33801234567", "tollFree"},
		{"+33 9 12 34 56 78", "", "+33912345678", "voip"},
		{"+33 884 01 23 45", "", "+33884012345", "sharedCost"},
		{"+33 891 12 34 56", "", "+33891123456", "premiumRate"},
		{"+33 806 12 34 56", "", "+33806123456", "uan"},
		{"07400 123456", "GB", "+447400123456", "mobile"},
		{"+44 (0)7400 123456", "", "+447400123456", "mobile"},
		{"00 1 201-555-0123", "GB", "+12015550123", "fixedLineOrMobile"},
	}
	for _, c := range cases {
		n, ok := Parse(c.text, c.region)
		if !ok || n.E164 != c.e164 || n.Type != c.typ {
			t.Errorf("Parse(%q, %q) = %+v, %v; want %s %s", c.text, c.region, n, ok, c.e164, c.typ)
		}
	}
}

func TestParseRejectsWhatNoPlanAccepts(t *testing.T) {
	cases := []struct{ text, region string }{
		{"12", "GB"},
		{"+44 7400 12345", ""},
		{"+49 1512 3456789", ""},
		{"07400 123456", ""},
	}
	for _, c := range cases {
		if n, ok := Parse(c.text, c.region); ok {
			t.Errorf("Parse(%q, %q) = %+v; want no match", c.text, c.region, n)
		}
	}
}

func TestTelAndDialFrom(t *testing.T) {
	if n, _ := Parse("+44 7400 123456", ""); n.Tel() != "tel:+447400123456" {
		t.Errorf("Tel() = %q", n.Tel())
	}
	cases := []struct{ text, from, want string }{
		{"+1 201-555-0123", "GB", "0012015550123"},
		{"+44 7400 123456", "US", "011447400123456"},
		{"+44 7400 123456", "GB", "07400123456"},
	}
	for _, c := range cases {
		n, _ := Parse(c.text, "")
		if got := n.DialFrom(c.from); got != c.want {
			t.Errorf("Parse(%q).DialFrom(%q) = %q; want %q", c.text, c.from, got, c.want)
		}
	}
}

What is specific to Go

  • regexp is RE2: matching takes linear time and there are no backreferences or lookarounds. The plans' patterns use neither, so every one of them compiles.
  • MatchString finds a match anywhere in the string: the patterns are compiled anchored, ^(?:…)$. Without the m flag, $ in Go means the end of the text.
  • MustCompile at package level panics when the program starts if a pattern is wrong, never in the middle of a request.
  • \d is ASCII in Go; raw string literals (backquotes) keep the patterns as the plans write them.

Sources of these plans: National Telephone Numbering Plan (Ofcom); National numbering plans, ITU-T E.164 (ITU); North American Numbering Plan Administrator (NANPA); Canadian Numbering Administrator (CNAC); Plan national de numérotation (ARCEP)