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.

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 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
}
Ranges, formats, trunk prefixes and example numbers all come from libphonenumber's own XML, and a differential test holds them there.
Nothing outside the standard library reaches your build.
Parsing an international number into an existing Phone
allocates nothing at all.
Geocoding, carrier and time zone data live in separate packages, so a program that never asks does not link them.
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())
| Option | Effect |
|---|---|
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 "". |
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.
Two different questions, often confused:
| Call | Asks |
|---|---|
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"))
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.
| Call | Result 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])
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.
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.
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.
| Grade | Meaning |
|---|---|
ExactMatch | Same number, same country, same extension. |
NSNMatch | Same national number; one side did not say the country. |
ShortNSNMatch | One is a suffix of the other — a local number that might be the same line. |
NoMatch | Different 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.
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
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.
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.
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.
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.
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.
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.
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".
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:
| Prefix | Carrier |
|---|---|
| 33 | HUMANS |
| 50, 93, 94 | Ucell |
| 77, 95, 99 | Uzbektelecom |
| 88, 97 | MobiUZ |
| 90, 91 | Beeline |
| 98 | Perfectum |
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.
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:
| Imports | Binary |
|---|---|
phonex alone | 3.8 MB |
+ timezone | 3.9 MB |
+ carrier | 4.1 MB |
+ geocoding | 7.2 MB |
all, plus shortnumber | 7.9 MB |
Geocoding and carrier data is English only.
Apple M-series, Go 1.26, -benchtime=1s:
| Benchmark | Time | Allocations |
|---|---|---|
| Parse, international | ~56 ns | 0 |
| Parse, national | ~365 ns | 1 |
| Parse, shared calling code | ~494 ns | 0 |
AppendE164 | ~4.4 ns | 0 |
geocoding.Area | ~150 ns | 0 |
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.
Everything phonex knows about numbers is generated from libphonenumber
v9.0.32, vendored verbatim under
internal/metadata/:
| Source | Generates |
|---|---|
PhoneNumberMetadata.xml | countries/ — ranges, formats, prefixes |
ShortNumberMetadata.xml | shortnumber/ |
geocoding/en/ | geocoding/ |
carrier/en/ | carrier/ |
timezones/map_data.txt | timezone/ |
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.
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.
| Don't | Do |
|---|---|
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. |
Abbreviated. The full reference, with every signature and doc comment, is on pkg.go.dev.
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
(*Phone).IsValid() / IsValidForRegion(region) / IsPossible() bool
(*Phone).Possibility() / PossibilityForType(t) Possibility
(*Phone).Type() PhoneType
(*Phone).CanBeInternationallyDialled() / MobileNumberPortable() bool
(*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
(*Phone).Country() / ISO2() / ISO3() / CountryName() / DialCode() string
(*Phone).NSN() / NationalDigits() / Digits() / Extension() string
(*Phone).CarrierCode() / RawInput() string
(*Phone).Metadata() *Metadata
(*Phone).Source() CountryCodeSource
(*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
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)
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