On this page
E.164 is the canonical way to write a phone number: +, the country code, then the national number, with no spaces and at most 15 digits. Converting every input to E.164 before you store, deduplicate or send is the single cheapest data-quality fix in a phone pipeline. This guide shows how to do it in JavaScript and Python, and which inputs trip people up.
What exactly is an E.164 number?
E.164 is the international numbering plan published by the ITU (ITU-T E.164, 02/2026 edition). A number in this format has three parts: a + that stands for the international dialling prefix, a country code of one to three digits, and the national significant number without any trunk prefix. The whole thing is at most 15 digits.
| Input as a person typed it | E.164 |
|---|---|
07911 123456 (typed in the UK) | +447911123456 |
+44 (0)20 7123 4567 | +442071234567 |
0049 151 23456789 | +4915123456789 |
(415) 555-2671 (typed in the US) | +14155552671 |
8 (912) 345-67-89 (typed in Russia) | +79123456789 |
All five come from real parsing runs with libphonenumber-js 1.13 (September 2026). The rows show three different trunk prefixes being removed: the UK 0, the German 0 after 0049, and the Russian 8. The E.164 glossary entry covers the basics. The rest of this guide is about what goes wrong in production.
Why not just strip non-digits and add a plus?
Because the digits alone don't tell you the country, and some digits must be removed, not kept. A naive replace(/\D/g, "") gets these cases wrong:
- Trunk prefixes.
07911 123456becomes07911123456. Adding+44in front gives+4407911123456, which is not a number. The leading0has to go. - International access codes.
0049 151…and00 91 98765 43210start with00, which means "international" in most of the world. In North America the access code is011. - The "(0)" convention.
+44 (0)20 7123 4567is common on UK and German business cards. The(0)tells domestic callers to dial a 0. It is not part of the E.164 number. - National format with no country.
4155552671could be a US number, or it could be read as+41 55 552 671, a Swiss-looking number. Only context decides.
A numbering-plan library solves the first three and makes you state the fourth. Google's libphonenumber is the reference implementation, and ports exist for most languages. It carries metadata about each country's ranges and number lengths, so it can reject numbers that can't exist, not just format them.
How do you normalize a number in JavaScript?
In Node.js or the browser, use libphonenumber-js. Import the /max metadata if you want real validation and line-type hints. The smaller default metadata only checks lengths.
import { parsePhoneNumberFromString } from "libphonenumber-js/max";
export function toE164(input, defaultCountry) {
const raw = String(input ?? "").trim();
if (!raw || raw.length > 32) return { ok: false, reason: "unparseable" };
const p = parsePhoneNumberFromString(raw, defaultCountry); // defaultCountry: "GB", "US", …
if (!p) return { ok: false, reason: "unparseable" };
if (!p.isValid()) return { ok: false, reason: "invalid" };
return { ok: true, e164: p.number, country: p.country ?? null, type: p.getType() ?? null };
}
toE164("07911 123456", "GB"); // { ok: true, e164: "+447911123456", country: "GG", type: "MOBILE" }
toE164("+44 (0)20 7123 4567"); // { ok: true, e164: "+442071234567", country: "GB", type: "FIXED_LINE" }
toE164("+1 (415) 555-2671"); // { ok: true, e164: "+14155552671", country: "US", type: "FIXED_LINE_OR_MOBILE" }
toE164("415-555-2671", "GB"); // { ok: false, reason: "invalid" }Two details matter. isValid() checks the number against the country's ranges, while isPossible() only checks the length. Use isValid() before paying for anything downstream. Also, p.number is already the E.164 string. Don't rebuild it yourself from countryCallingCode and nationalNumber.
How do you normalize a number in Python?
The phonenumbers package is the Python port of libphonenumber. Its core API is parse, is_valid_number, format_number and number_type:
import phonenumbers
from phonenumbers import NumberParseException, PhoneNumberFormat, PhoneNumberType
def to_e164(raw: str, default_region: str | None = None):
try:
p = phonenumbers.parse(raw, default_region) # region like "GB"; None requires a leading +
except NumberParseException:
return None, "unparseable"
if not phonenumbers.is_valid_number(p):
return None, "invalid"
e164 = phonenumbers.format_number(p, PhoneNumberFormat.E164)
return e164, phonenumbers.number_type(p) # e.g. PhoneNumberType.MOBILE
to_e164("07911 123456", "GB") # ('+447911123456', PhoneNumberType.MOBILE)
to_e164("4155552671") # raises inside parse -> (None, 'unparseable'): no region, no +parse raises NumberParseException when it can't interpret the input at all, for example national digits with no region. Catch it and treat it like an invalid number. number_type returns an enum such as MOBILE, FIXED_LINE, FIXED_LINE_OR_MOBILE, VOIP or TOLL_FREE. That value comes from the numbering plan, so it describes the range, not the current service. Pin the library version and upgrade it regularly: numbering plans change, and the metadata ships with the package.
Which inputs cause the most surprises?
These are from our own parsing runs, not from theory:
| Input | What happens | Why it matters |
|---|---|---|
+44 7911 123456 | Country comes back as GG (Guernsey), not GB | +44 is shared by the UK, Guernsey, Jersey and the Isle of Man. Don't map country code to country with a lookup table |
+1 800 555 0199 | Valid, type TOLL_FREE | Valid is not the same as "can receive SMS" |
+1 415 555 2671 | Type FIXED_LINE_OR_MOBILE | US and Canadian ranges don't separate mobile from fixed. You need a carrier lookup to know |
+33 06 12 34 56 78 | Parsed as +33612345678 | The library drops a trunk 0 written after the country code |
+44 7911 123456 ext 12 | E.164 +447911123456, extension 12 kept separately | E.164 has no extensions. Store the extension in its own field or you lose it |
+447911123456 (full-width digits) | Parsed correctly | Copy-paste from some keyboards and PDFs produces non-ASCII digits. Don't reject them with a strict regex before parsing |
447911123456 with no region | Not parsed by the library | Whether bare digits mean "international" is your decision, not the library's |
The Guernsey case is the one that silently breaks analytics. If your dashboard groups by country and expects every +44 number to be GB, a slice of your UK traffic disappears into a country you never selected.
How does the MobileValidate API normalize numbers?
The API converts every number to E.164 before deduplication, caching, pricing or any check, using libphonenumber metadata. You can send any format. The rules:
- Input is trimmed. Empty strings and inputs over 32 characters are rejected.
- If the input is 8 to 15 bare digits and the request has no
default_country, a+is added, so the digits are read as an international number with country code. - Otherwise the input is parsed with
default_country(ISO 3166-1 alpha-2, one value per request). - The number must be valid for its numbering plan, not just the right length. Otherwise
number_statusisinvalid_number. - After conversion, repeats of the same E.164 number in one request are marked
duplicate.
Invalid and duplicate rows are never checked. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate). Here is a real test-mode request with mixed formats and default_country: "GB":
curl https://api.mobilevalidate.com/v1/lookup \
-H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"numbers": ["+44 (0)20 7123 4567", "07911 123456", "447911123456", "0049 151 23456789",
"415-555-2671", "+44 7911 123456 ext 12"],
"checks": ["carrier"], "default_country": "GB", "wait": 5}'The number fields of each result (excerpt):
[
{"input": "+44 (0)20 7123 4567", "e164": "+442071234567", "country": "GB", "number_status": "valid"},
{"input": "07911 123456", "e164": "+447911123456", "country": "GG", "number_status": "valid"},
{"input": "447911123456", "e164": "+447911123456", "country": "GG", "number_status": "duplicate"},
{"input": "0049 151 23456789", "e164": "+4915123456789", "country": "DE", "number_status": "valid"},
{"input": "415-555-2671", "e164": null, "country": null, "number_status": "invalid_number"},
{"input": "+44 7911 123456 ext 12", "e164": "+447911123456", "country": "GG", "number_status": "duplicate"}
]Three different inputs collapse into one number, so only one of them is checked and billed. The US number in national format is invalid because the request's default country is GB.
What goes wrong with default_country?
default_country applies to the whole request, so a mixed-country list in national format will lose rows. Our test runs show two traps:
- Wrong default.
415-555-2671in a request withdefault_country: "GB"isinvalid_number. The same number with"US", or written as+1 415 555 2671, is valid. - No default.
4155552671with nodefault_countrygets a+and becomes+4155552671. That is read as country code 41, fails validation and comes backinvalid_number.
The fix is to keep the country with the number from the start. Capture it on the form (a country picker next to the phone field), store it with the record, and when you batch, group numbers by country and send one request per group. If your source data is already international, + plus country code, leave default_country out. For bulk lists, POST /v1/jobs/estimate shows the counts of valid, invalid and duplicate rows for free, so you can catch a wrong default before running a job. See how to clean a phone number list in bulk.
How should you store and compare numbers?
A schema that avoids most future bugs:
| Column | Example | Notes |
|---|---|---|
phone_e164 | +447911123456 | The canonical value. Index it, deduplicate on it, join on it |
phone_raw | 07911 123456 | What the person typed, for support. Don't use it for logic |
phone_country | GG | From the parser, not from the country code |
phone_ext | 12 | Only if you accept extensions |
phone_checked_at | 2026-09-25T16:06:07Z | When any lookup last ran, so you know how old the data is |
Store E.164 as a string, never an integer. Compare with exact string equality after normalization. Mask numbers in logs (for example +44791*****56), because a full phone number is personal data. For tests and documentation, use ranges that are never assigned: Ofcom reserves 07700 900000 to 07700 900999 for drama (Ofcom), and our test mode uses numbers from that range.
What doesn't E.164 tell you?
A valid E.164 number is a number that could exist. It isn't proof that the number is assigned, switched on, a mobile, or used by the person who typed it. Once the format is right, the next questions are:
- What kind of line is it? The library's type is range-based. A carrier lookup returns the current
line_typeand carrier, which matters in the US and Canada and for ported numbers. - Is it live? That needs a network query. Our HLR lookup is coming soon. See how to check if a phone number is active.
- Does it use a given channel? Channel checks such as the WhatsApp check answer that for opted-in contacts.
HLR vs MNP vs number validation compares these layers side by side.
What are the key takeaways?
- Normalize to E.164 with a numbering-plan library (libphonenumber or a port), not with regexes.
- Use full validation (
isValid()/is_valid_number) rather than length checks before you pay for any downstream check. - Keep the country with the number.
default_countryapplies to a whole request, so group mixed lists by country. - Don't infer country from country code:
+44can come back asGG, and+1covers many countries. - Deduplicate after normalization. The API does this for you, and duplicates are never charged.
- Store E.164 as a string, keep the raw input and the extension separately, and mask numbers in logs.
Sources
- ITU-T Recommendation E.164: The international public telecommunication numbering plan — International Telecommunication Union, 2026
- libphonenumber — Google, 2026
- Telephone numbers for use in TV and radio drama programmes — Ofcom, 2026
Frequently asked questions
What is the E.164 format?
A plus sign, the country code and the national number, with no spaces or punctuation and at most 15 digits. +447911123456 is a UK-format mobile number written in E.164. The format comes from ITU-T Recommendation E.164.
Should I store phone numbers as integers?
No. Store the E.164 string. Integers lose the leading plus, can overflow in some languages, and invite arithmetic nobody should do on a phone number. Keep the raw input in a separate field if you need it for support.
Why does a US number fail when my default country is GB?
A number without a leading + or 00 is read in the default country's numbering plan. 415-555-2671 is a valid US number but not a valid UK number, so it is rejected. Send international format, or group numbers by country and send one default_country per request.
Does a valid E.164 number mean the phone works?
No. Validation checks the format and the numbering plan. It says nothing about whether the number is assigned, switched on or used on a messaging app. Use a carrier lookup or channel checks for that.
Related services and guides
More from the blog
All articlesDeliverability
How to clean a phone number list in bulk, step by step
A step-by-step way to clean a phone list with the MobileValidate jobs API: free estimate, dedupe, invalid rows, capped cost, CSV download and data purge.
7 min read
Guides
HLR lookup vs MNP lookup vs number validation: what each one answers
Validation checks the digits, MNP finds the current network, HLR asks the network if the number is live. What each answers, costs and misses.
7 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


