phonex

Phone number parsing, validation and formatting for Go — generated directly from Google's libphonenumber, with no dependencies and no allocations on the hot path.

Go Reference CI Go Report Card MIT licence

Install #

go get github.com/bakhod1r/phonex

Go 1.26 or newer. The library itself has no dependencies — everything it needs is generated into the module.

package main

import (
	"fmt"

	"github.com/bakhod1r/phonex"
)

func main() {
	p, err := phonex.Parse("+998 90 123 45 67")
	if err != nil {
		return
	}

	fmt.Println(p.E164())          // +998901234567
	fmt.Println(p.International()) // +998 90 123 45 67
	fmt.Println(p.National())      // 90 123 45 67
	fmt.Println(p.Country())       // UZ
	fmt.Println(p.Type())          // MOBILE
	fmt.Println(p.IsValid())       // true
}

Same answers as upstream

Ranges, formats, trunk prefixes and example numbers all come from libphonenumber's own XML, and a differential test holds them there.

No dependencies

Nothing outside the standard library reaches your build.

No allocations

Parsing an international number into an existing Phone allocates nothing at all.

Pay for what you import

Geocoding, carrier and time zone data live in separate packages, so a program that never asks does not link them.

Parsing #

An international number carries its own country, so it parses on its own. A national one does not, and needs the region it was written in:

p, err := phonex.Parse("+44 20 7031 3000")
q, err := phonex.Parse("020 7031 3000", phonex.WithDefaultCountry("GB"))

Input is taken as written by a human. Spaces, dashes, brackets, dots, non-ASCII digits, an 00 or 011 international prefix, the region's own trunk prefix, tel: URIs and extensions are all handled.

phonex.Parse("(020) 7031-3000", phonex.WithDefaultCountry("GB"))
phonex.Parse("tel:+44-20-7031-3000;ext=42")
phonex.Parse("00 44 20 7031 3000", phonex.WithDefaultCountry("DE"))
phonex.Parse("+1 800 FLOWERS", phonex.WithAlphaCharacters())

Options

OptionEffect
WithDefaultCountry(r)Region to assume when the number carries no calling code.
WithAlphaCharacters()Translate vanity letters to digits. Off by default, since it makes typos parse.
WithoutRawInput()Drop the original string. RawInput() returns "".

Parsing without allocating

Reuse a Phone and the parse costs nothing in garbage:

var p phonex.Phone
for _, s := range numbers {
	if err := p.Parse(s); err != nil {
		continue
	}
	buf = p.AppendE164(buf[:0])
	// … use buf …
}

Parse is deliberately permissive. It accepts anything shaped like a phone number, exactly as libphonenumber does, and leaves the judgement to IsValid. A successful parse is not a claim that the number exists.

Validation #

Two different questions, often confused:

CallAsks
p.IsPossible()Is this a length the region uses at all?
p.IsValid()Does it fall inside a range that has actually been assigned?
p.IsValidForRegion(r)Valid, and belonging to that region — the check to use where several regions share a calling code.
p.Possibility()Why a length check failed.

Possibility reports IsPossibleNumber, IsPossibleLocalOnly, TooShort, InvalidLength, TooLong or InvalidCountryCode — enough to tell a user what to fix rather than only that something is wrong.

p, _ := phonex.Parse("+998 90 123 456")
p.IsValid()      // false
p.Possibility()  // TOO_SHORT

For a one-off check without keeping the number, the package-level forms take the same options:

phonex.IsValid("+998 90 123 45 67")
phonex.IsPossible("90 123 45 67", phonex.WithDefaultCountry("UZ"))
phonex.ToE164("90 123 45 67", phonex.WithDefaultCountry("UZ"))

Number types #

p.Type() returns one of libphonenumber's eleven ranges, and each has a predicate:

p.IsMobile()             p.IsTollFree()        p.IsPremiumRate()
p.IsLandline()           p.IsSharedCost()      p.IsVoIP()
p.IsPersonalNumber()     p.IsPager()           p.IsUAN()
p.IsVoicemail()          p.IsFixedLineOrMobile()

Some countries do not separate fixed lines from mobiles at all. There Type() returns FIXED_LINE_OR_MOBILE, and both IsMobile() and IsLandline() are false — the honest answer is that the metadata cannot tell. Use IsFixedLineOrMobile() if either will do.

Formatting #

CallResult for +44 20 7031 3000
p.E164()+442070313000
p.International()+44 20 7031 3000
p.National()020 7031 3000
p.RFC3966()tel:+44-20-7031-3000
p.OutOfCountry("US")011 44 20 7031 3000

OutOfCountry is the one to dial: it uses the caller's own international prefix, and knows the cases where the rule is not simply "add the calling code" — dialling Mexico or Argentina from abroad, or the NANP countries that dial each other as if domestic.

Store E164(). Show National() to someone in the same country and International() to everyone else.

// Append into a buffer you already own; allocates nothing.
buf = p.AppendE164(buf[:0])

As-you-type formatting #

For an input field, formatting the number while it is still being typed:

f := phonex.NewFormatter("US")
f.InputDigit('2')      // 2
f.InputDigit('0')      // 20
f.InputDigit('2')      // 202
f.InputDigit('5')      // 202-5
f.Input("550123")   // (202) 555-0123

f.RemoveLastDigit()  // (202) 555-012
f.Clear()

The grouping changes as the number grows — 202-5 becomes (202) 555-0123 once there are enough digits to tell which format applies. Digits are never lost, only regrouped, which is the property an input field needs.

Digits are taken one at a time because that is how a keystroke arrives. Anything that is not a digit is ignored, except a leading +, which switches the formatter to international mode.

Extensions #

p, _ := phonex.Parse("+1 212 555 0123 ext. 42")
p.HasExtension()  // true
p.Extension()     // 42
p.E164()          // +12125550123 — E.164 has no room for one
p.RFC3966()       // tel:+1-212-555-0123;ext=42

ext, x, #, anexo, interno, ramal and the rest of libphonenumber's markers are recognised. Store the extension separately: it survives RFC3966() and nothing else.

Comparing numbers #

Two strings can be the same number written differently, or may only look alike. Match grades the relationship:

us := phonex.WithDefaultCountry("US")

phonex.MatchNumbers("+1 212 555 0123", "+12125550123")          // EXACT_MATCH
phonex.MatchNumbers("+1 212 555 0123", "(212) 555-0123", us)   // EXACT_MATCH
phonex.MatchNumbers("+1 212 555 0123", "555 0123", us)         // SHORT_NSN_MATCH
phonex.MatchNumbers("+1 212 555 0123", "(212) 555-0123")       // NO_MATCH

The last line is not a bug. A national string with no default region does not parse, and phonex will not guess a country in order to make two numbers agree — pass the region and the same comparison becomes exact.

GradeMeaning
ExactMatchSame number, same country, same extension.
NSNMatchSame national number; one side did not say the country.
ShortNSNMatchOne is a suffix of the other — a local number that might be the same line.
NoMatchDifferent numbers.

ShortNSNMatch is a hint, not an identity. Do not merge two records on it.

phonex.Equal(a, b) is the blunt form, true for exact and NSN matches. p.EqualExact(q) compares extensions too.

Country metadata #

c, _ := phonex.Country("UZ")
c.Name         // Uzbekistan
c.DialCode     // 998
c.ISO3         // UZB

phonex.CountryByDialCode("44")      // GB — the main region for the code
phonex.RegionsForDialCode("1")     // US, CA, BS, … all 25
phonex.CountryByPhone("+998901234567")
phonex.SearchCountries("uzbek")
phonex.SupportedRegions()             // every region, sorted
phonex.NonGeoEntities()               // 800, 870, 882 and friends

Example numbers come straight from the metadata, which makes them useful for tests and placeholder text:

p, _ := phonex.ExampleNumber("GB")
q, _ := phonex.ExampleNumberForType("GB", phonex.Mobile)
r, _ := phonex.Generate("GB")  // a random valid number

Generating numbers

Generate keeps an example's area and operator digits and randomises the subscriber part, so the result is a number IsValid accepts. It draws from the global math/rand. Where the output has to be reproducible from a seed, supply the randomness instead — intn returns a value in [0,n), so any generator fits:

rng := rand.New(rand.NewSource(1))

phonex.GenerateWith("GB", phonex.Mobile, rng.Intn)   // same seed, same number
phonex.GenerateWith("GB", phonex.AnyType, rng.Intn)  // any range the region has

phonex.GenerateForPrefix("GB", "20", rng.Intn)     // +44 20 xxxx xxxx, London
phonex.GenerateForPrefix("UZ", "93", rng.Intn)     // +998 93 xxx xx xx, Ucell

A prefix may be written either way round. National number lengths vary within a country — London's 20 takes eight further digits where most UK codes take seven — and some plans count the trunk digit as part of the national number, so Rome is 06 to phonex and 6 in an atlas. Both readings are tried, and the shape that fits is cached.

Generated numbers are valid, which means they may well belong to a real subscriber. Never dial or message them.

Storage and encoding #

Phone implements the four interfaces you would want it to, all round-tripping through E.164:

type Contact struct {
	Phone *phonex.Phone `json:"phone"`
}
// {"phone":"+998901234567"}

row.Scan(&p)                      // sql.Scanner
db.Exec("…", p)                  // driver.Valuer
p.MarshalText() / UnmarshalText   // encoding.TextMarshaler

Store the E.164 string. It is unambiguous, sorts sensibly, and reparses into exactly the same number.

Privacy helpers #

p, _ := phonex.Parse("+998 90 123 45 67")

p.Mask()                  // +998*******67
p.Mask(phonex.MaskLast4)  // *********4567
phonex.Redact(p)          // +998*******67 — for logs

p.Hash()                                      // SHA-256 of the E.164 form
phonex.Fingerprint(p, phonex.WithSecret(key)) // HMAC-SHA256

MaskOptions takes the number of digits to keep at each end and the character to mask with, so MaskLast4 is simply {Suffix: 4, Mask: '*'}. Both hashes are computed over the canonical E.164 form, so the same number written differently hashes alike.

A plain hash of a phone number is not anonymous. The whole space is small enough to enumerate, so anyone with a hash can find the number by trying all of them. If the hash leaves your system, use Fingerprint with a secret key so that it cannot be reversed without it.

Short numbers #

112, 911 and 10086 are not phone numbers in the E.164 sense: they carry no calling code, only work inside one country, and the same digits mean different things in different places. The main package rejects them as too short, and the shortnumber package handles them from its own metadata.

import "github.com/bakhod1r/phonex/shortnumber"

shortnumber.IsEmergency("112", "GB")          // true
shortnumber.IsEmergency("112", "UZ")          // false — UZ dials 01, 02, 03
shortnumber.IsValid("100", "GB")              // true — the BT operator
shortnumber.ExpectedCost("10086", "CN")      // STANDARD_RATE
shortnumber.IsCarrierSpecific("202", "GB")     // true — some networks only

Before dialling automatically, use ConnectsToEmergency, not IsEmergency. Networks in most countries act on the emergency prefix alone, so 911123 still reaches the emergency services even though it is not an emergency number. Brazil, Chile and Nicaragua are the exceptions, and the function knows them.

Carrier lookup #

import "github.com/bakhod1r/phonex/carrier"

p, _ := phonex.Parse("+998 93 123 45 67")
carrier.Name(p)                             // Ucell
carrier.NameForDigits("998931234567")       // Ucell, without parsing
carrier.NameForNumber("93 123 45 67", phonex.WithDefaultCountry("UZ"))
carrier.SafeDisplayName(p)                  // Ucell — see below
carrier.Count()                             // 28962 prefixes

The answer comes from a prefix table, which has three consequences worth knowing before showing it to anyone.

It is the issuing network, not today's

Where subscribers can keep their number when they switch operator, the table cannot know that they did. Name still returns the original network. SafeDisplayName returns "" in those regions instead, and is the one to use for anything a user will read. Uzbekistan is not among them, so both agree there.

Coverage is uneven, and the gaps are deliberate

The data covers 206 calling codes, but only the ranges upstream is confident about. There are no entries for United States or Russian mobile numbers at all — portability there makes a prefix table close to meaningless — so Name returns "". An empty result means "not in the data", never "no such carrier".

Only mobile ranges are covered

A fixed line returns "", because a landline belongs to whoever runs the exchange rather than to a network in this sense.

For Uzbekistan the whole mobile table is short enough to print:

PrefixCarrier
33HUMANS
50, 93, 94Ucell
77, 95, 99Uzbektelecom
88, 97MobiUZ
90, 91Beeline
98Perfectum

Anything else — +998 59 …, say — is not an assigned range, and IsValid reports that before carrier lookup becomes a question. A lookup takes about 150 ns and allocates nothing.

Geocoding and time zones #

import (
	"github.com/bakhod1r/phonex/geocoding"
	"github.com/bakhod1r/phonex/timezone"
)

p, _ := phonex.Parse("+44 20 7031 3000")
geocoding.Area(p)      // London
geocoding.Describe(p)  // London, or the country when there is no area
timezone.For(p)        // [Europe/London]

Read the answers for what they are. All three data sets key off the number's prefix, so they describe where and how the number was issued, not where its owner is today. A mobile number keeps its area and time zone when its owner emigrates.

They are separate packages because the data is large and most programs need none of it. A hello-world binary, Go 1.26 on darwin/arm64:

ImportsBinary
phonex alone3.8 MB
+ timezone3.9 MB
+ carrier4.1 MB
+ geocoding7.2 MB
all, plus shortnumber7.9 MB

Geocoding and carrier data is English only.

Performance #

Apple M-series, Go 1.26, -benchtime=1s:

BenchmarkTimeAllocations
Parse, international~56 ns0
Parse, national~365 ns1
Parse, shared calling code~494 ns0
AppendE164~4.4 ns0
geocoding.Area~150 ns0

Three things make that possible: the number lives in fixed-size arrays inside the Phone rather than on the heap; the calling code is found by indexing a fixed-size table instead of searching; and the region's regexps compile on first use, so a program that touches five countries pays for five.

Metadata #

Everything phonex knows about numbers is generated from libphonenumber v9.0.32, vendored verbatim under internal/metadata/:

SourceGenerates
PhoneNumberMetadata.xmlcountries/ — ranges, formats, prefixes
ShortNumberMetadata.xmlshortnumber/
geocoding/en/geocoding/
carrier/en/carrier/
timezones/map_data.txttimezone/

Each generated package carries a SourceHash of the data it came from, and a test recomputes it from the vendored files. Editing a generated file by hand, or regenerating from a different release without saying so, fails the build.

Differential testing #

Agreeing with libphonenumber is a claim that can be checked rather than asserted. difftest/ is a separate module — so its comparison dependency never reaches library users — that parses a corpus of about 12,800 numbers with both implementations and compares the results.

Across the corpus there is no disagreement on: whether a number parses, its E.164 form, region, type, possibility, out-of-country form, geocoding area, time zone, or any of the short-number checks. A handful of cases differ on validity, formatting and carrier name — every one traced to the comparison library bundling data from a different upstream snapshot, and documented at the call site.

Verification also includes 11M+ fuzz executions, a race-detector run with sixteen concurrent readers, and a check that every example number in the metadata parses, validates, and round-trips through all five formats.

Common mistakes #

Don'tDo
Treat a successful Parse as "valid". Parse tells you the shape is plausible. Call IsValid().
Store the number as typed. Store E164(); format on the way out.
Use IsValid() where a calling code is shared. IsValidForRegion("CA")+1 covers 25 regions.
Show carrier.Name to a user. SafeDisplayName, which stays quiet where portability makes the answer unreliable.
Read geocoding.Area as where someone is. It is where the number was issued. People move; numbers do not.
Publish p.Hash(). Fingerprint with a secret. Unsalted hashes of phone numbers are trivially reversed.
Auto-dial on IsEmergency. ConnectsToEmergency, which allows for digits typed after.
Validate with a regexp of your own. That is what the metadata is for.

API index #

Abbreviated. The full reference, with every signature and doc comment, is on pkg.go.dev.

Parsing

phonex.Parse(input, opts...) (*Phone, error)
phonex.ParseBytes(input, opts...) (*Phone, error)
phonex.ParseWith(input, options) (*Phone, error)
(*Phone).Parse(input, opts...) error          // reuses the receiver
phonex.ParseMany(numbers, opts...) []Result

Validity

(*Phone).IsValid() / IsValidForRegion(region) / IsPossible() bool
(*Phone).Possibility() / PossibilityForType(t) Possibility
(*Phone).Type() PhoneType
(*Phone).CanBeInternationallyDialled() / MobileNumberPortable() bool

Formatting

(*Phone).E164() / International() / National() / RFC3966() string
(*Phone).OutOfCountry(fromRegion) / NationalWithCarrier(code) string
(*Phone).Format(f FormatType) string
(*Phone).AppendE164(dst []byte) []byte
phonex.NewFormatter(region) *Formatter

Identity

(*Phone).Country() / ISO2() / ISO3() / CountryName() / DialCode() string
(*Phone).NSN() / NationalDigits() / Digits() / Extension() string
(*Phone).CarrierCode() / RawInput() string
(*Phone).Metadata() *Metadata
(*Phone).Source() CountryCodeSource

Comparison

(*Phone).Match(other) MatchType
(*Phone).Equal(other) / EqualExact(other) bool
phonex.MatchNumbers(a, b, opts...) MatchType
phonex.Equal(a, b, opts...) bool
phonex.Unique(numbers, opts...) / SortNumbers(numbers, opts...) []string

Countries

phonex.Country(iso2) (*Metadata, bool)
phonex.CountryByDialCode(code) / CountryByPhone(number, opts...) (*Metadata, bool)
phonex.RegionsForDialCode(code) / Countries() / NonGeoEntities() []*Metadata
phonex.SearchCountries(query) []*Metadata
phonex.SupportedRegions() []string
phonex.ExampleNumber(region) / Generate(region) (*Phone, bool)
phonex.GenerateForType(region, t) (*Phone, bool)
phonex.GenerateWith(region, t, intn func(int) int) (*Phone, bool)
phonex.GenerateForPrefix(region, prefix, intn func(int) int) (*Phone, bool)

Optional packages

carrier.Name(p) / SafeDisplayName(p) / NameForDigits(digits) string
geocoding.Area(p) / Describe(p) / AreaForDigits(digits) string
timezone.For(p) / ForDigits(digits) []string
shortnumber.IsValid(n, r) / IsEmergency(n, r) / ConnectsToEmergency(n, r) bool
shortnumber.ExpectedCost(n, r) Cost