Validate a phone number and its country code

Tell whether a number someone typed exists in a country’s numbering plan, and what kind of line it is.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the answer is in a published table, and the real difficulty is not reading it but refusing to invent what is missing. The snippet turns down a number with no country code when the caller has declared no region, instead of assuming yours: 0470 12 34 56 comes back as a French fixed line or a Belgian mobile depending on what is declared, and both are valid. This level’s price is visible and paid about once a year: the table ages — a Benin number from before the move to ten digits is refused — and you update it like any other dependency.

libphonenumber’s numbering plans, with the region declared by the caller

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/validate-a-phone-number-and-its-country-code/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Validate a phone number against the numbering plan of its country.

Rung N0. Every country publishes which prefixes exist, how long a number is
behind each of them, and what kind of line it reaches. Google collects those
plans in libphonenumber and republishes them as they change — `phonenumbers`
here, `libphonenumber-js` in JavaScript. Both are a table lookup: no network,
no key, and the same answer twice.

The one thing this code refuses to do is guess. A number written 06 12 34 56 78
is French only if somebody says so; the same digits are a valid mobile number
in several other countries. So the region is either written in the number, as
the leading +, or declared by the caller, or the number is refused — never
assumed from where the server happens to run.

What comes back is the number in E.164, the region it belongs to, and the kind
of line. The kind is what tells a form that an SMS will never arrive: in France
a number starting 01 is a fixed line, and it is valid.
"""

from __future__ import annotations

import unicodedata

import phonenumbers
from phonenumbers import PhoneNumberFormat, PhoneNumberType

# The enumeration of libphonenumber, by name, so that both languages of this
# page return the same word for the same line.
TYPE_NAMES = {
    value: name
    for name, value in vars(PhoneNumberType).items()
    if isinstance(value, int) and not name.startswith("_")
}

# E.164 caps a number at fifteen digits. Forty characters leaves room for the
# spaces, dots and brackets people type, and refuses a pasted paragraph.
MAX_CHARACTERS = 40

# Everything a phone number may carry besides digits. Digits are any Unicode
# decimal digit, category Nd, which is what the JavaScript side matches too:
# a number typed in Arabic-Indic digits is a number.
PUNCTUATION = "+ .-()/"

# The space characters a word processor and a French keyboard put inside a
# number. libphonenumber has its own list of punctuation it steps over, and the
# narrow no-break space is not in it: a number grouped with those stops being
# read at the first one. They are levelled to an ordinary space, and nothing
# else is touched.
SPACES = "\t\n\r\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000"


def validate_phone_number(raw, *, default_region: str | None = None) -> dict:
    """
    Say whether `raw` is a number that exists in a numbering plan.

    `default_region` is the two-letter country the caller is expecting, and it
    is required unless the number starts with +. There is no fallback: a
    silently assumed region turns a Belgian mobile into a French one, and the
    caller would never see it happen.

    Nothing raises. A number that cannot be read comes back as `valid: False`
    with a `reason`, because a validator that throws in a request path leaves
    the form with nothing to show.
    """
    if not isinstance(raw, str):
        return _report(False, None, None, None, f"a phone number is text, not {type(raw).__name__}")
    if len(raw) > MAX_CHARACTERS:
        return _report(False, None, None, None, f"longer than {MAX_CHARACTERS} characters")

    written = "".join(" " if c in SPACES else c for c in raw).strip(" ")
    # Letters are refused rather than passed on. libphonenumber reads them as
    # the keys of a telephone keypad, so « 06 12 34 56 78 poste 42 » becomes a
    # longer number that is not the one anybody typed.
    if any(c not in PUNCTUATION and unicodedata.category(c) != "Nd" for c in written):
        return _report(False, None, None, None, "contains something that is not a number")
    if not written.startswith("+") and not default_region:
        return _report(False, None, None, None,
                       "no country code in the number and no region declared by the caller")

    try:
        parsed = phonenumbers.parse(written, default_region)
    except phonenumbers.NumberParseException:
        # The two libraries do not word their parse errors the same way, and
        # the caller acts on the refusal, not on its wording.
        return _report(False, None, None, None, "cannot be read as a phone number")

    region = phonenumbers.region_code_for_number(parsed)
    kind = TYPE_NAMES.get(phonenumbers.number_type(parsed))
    kind = None if kind == "UNKNOWN" else kind
    e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164)

    if not phonenumbers.is_valid_number(parsed):
        # `is_possible_number` only checks the length, so it separates a number
        # that is too short from one whose prefix is not allocated.
        detail = ("no prefix of that country's plan matches"
                  if phonenumbers.is_possible_number(parsed) else "the wrong length for its country")
        return _report(False, region, kind, e164, f"not in a numbering plan: {detail}")
    return _report(True, region, kind, e164, None)


def _report(valid: bool, region: str | None, kind: str | None,
            e164: str | None, reason: str | None) -> dict:
    return {"valid": valid, "region": region, "type": kind, "e164": e164, "reason": reason}

JavaScript

snippets/validate-a-phone-number-and-its-country-code/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Validate a phone number against the numbering plan of its country.
 *
 * Rung N0. Every country publishes which prefixes exist, how long a number is
 * behind each of them, and what kind of line it reaches. Google collects those
 * plans in libphonenumber and republishes them as they change —
 * `libphonenumber-js` here, `phonenumbers` in Python. Both are a table lookup:
 * no network, no key, and the same answer twice.
 *
 * The one thing this code refuses to do is guess. A number written
 * 06 12 34 56 78 is French only if somebody says so; the same digits are a
 * valid mobile number in several other countries. So the region is either
 * written in the number, as the leading +, or declared by the caller, or the
 * number is refused — never assumed from where the server happens to run.
 *
 * What comes back is the number in E.164, the region it belongs to, and the
 * kind of line. The kind is what tells a form that an SMS will never arrive:
 * in France a number starting 01 is a fixed line, and it is valid.
 */

// The `max` bundle, and not the default one: it is the metadata that carries
// the line types and the full per-country rules, which is what Python's
// `phonenumbers` ships by default.
import { parsePhoneNumberWithError } from 'libphonenumber-js/max';

// E.164 caps a number at fifteen digits. Forty characters leaves room for the
// spaces, dots and brackets people type, and refuses a pasted paragraph.
export const MAX_CHARACTERS = 40;

// Everything a phone number may carry besides digits. Digits are any Unicode
// decimal digit, category Nd, which is what the Python side matches too: a
// number typed in Arabic-Indic digits is a number.
export const PUNCTUATION = '+ .-()/';

// The space characters a word processor and a French keyboard put inside a
// number. libphonenumber has its own list of punctuation it steps over, and
// the narrow no-break space is not in it: a number grouped with those stops
// being read at the first one. They are levelled to an ordinary space, and
// nothing else is touched.
export const SPACES =
  '\t\n\r\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000';

/**
 * Say whether `raw` is a number that exists in a numbering plan.
 *
 * `defaultRegion` is the two-letter country the caller is expecting, and it is
 * required unless the number starts with +. There is no fallback: a silently
 * assumed region turns a Belgian mobile into a French one, and the caller
 * would never see it happen.
 *
 * Nothing throws. A number that cannot be read comes back as `valid: false`
 * with a `reason`, because a validator that throws in a request path leaves
 * the form with nothing to show.
 *
 * @param {string} raw
 * @param {{defaultRegion?: string}} [options]
 */
export function validatePhoneNumber(raw, { defaultRegion } = {}) {
  if (typeof raw !== 'string') {
    return report(false, null, null, null, `a phone number is text, not ${kindOf(raw)}`);
  }
  if (raw.length > MAX_CHARACTERS) {
    return report(false, null, null, null, `longer than ${MAX_CHARACTERS} characters`);
  }

  const written = [...raw].map((c) => (SPACES.includes(c) ? ' ' : c)).join('').replace(/^ +| +$/g, '');
  // Letters are refused rather than passed on. libphonenumber reads them as
  // the keys of a telephone keypad, so “06 12 34 56 78 poste 42” becomes a
  // longer number that is not the one anybody typed.
  if ([...written].some((c) => !PUNCTUATION.includes(c) && !/\p{Nd}/u.test(c))) {
    return report(false, null, null, null, 'contains something that is not a number');
  }
  if (!written.startsWith('+') && !defaultRegion) {
    return report(false, null, null, null,
      'no country code in the number and no region declared by the caller');
  }

  let parsed;
  try {
    parsed = parsePhoneNumberWithError(written, defaultRegion);
  } catch {
    // The two libraries do not word their parse errors the same way, and the
    // caller acts on the refusal, not on its wording.
    return report(false, null, null, null, 'cannot be read as a phone number');
  }

  const region = parsed.country ?? null;
  const kind = parsed.getType() ?? null;
  const e164 = parsed.number;

  if (!parsed.isValid()) {
    // `isPossible` only checks the length, so it separates a number that is
    // too short from one whose prefix is not allocated.
    const detail = parsed.isPossible()
      ? "no prefix of that country's plan matches"
      : 'the wrong length for its country';
    return report(false, region, kind, e164, `not in a numbering plan: ${detail}`);
  }
  return report(true, region, kind, e164, null);
}

const kindOf = (value) => (value === null ? 'null' : typeof value);

const report = (valid, region, type, e164, reason) => ({ valid, region, type, e164, reason });

Try it

This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.

Every output is the snippet’s own, run when the site is built. The region the caller declares is in the case label: the snippet never guesses it.

  1. A French mobile, region FR declared

    Given

    06 12 34 56 78

    What it returns

    { "valid": true, "region": "FR", "type": "MOBILE", "e164": "+33612345678", "reason": null }

    What it concludes

    Valid number — FR +33612345678

    A mobile line: a text message will arrive.

  2. A French fixed line, region FR declared

    Given

    01 23 45 67 89

    What it returns

    { "valid": true, "region": "FR", "type": "FIXED_LINE", "e164": "+33123456789", "reason": null }

    What it concludes

    Valid number — FR +33123456789

    A fixed line: a text message will not arrive.

  3. The same digits, region BE declared

    Given

    0470 12 34 56

    What it returns

    { "valid": true, "region": "BE", "type": "MOBILE", "e164": "+32470123456", "reason": null }

    What it concludes

    Valid number — BE +32470123456

    A mobile line: a text message will arrive.

  4. A number with no country code, and nothing declared

    Given

    06 12 34 56 78

    What it returns

    { "valid": false, "region": null, "type": null, "e164": null, "reason": "no country code in the number and no region declared by the caller" }

    What it concludes

    Refused

    no country code in the number and no region declared by the caller

    Here it breaksNo region is declared and the number carries none: the snippet refuses instead of assuming France. The case just above shows why — “0470 12 34 56” is a fixed line in France and a mobile in Belgium.

  5. A Benin number in the old eight-digit form

    Given

    +229 97 12 34 56

    What it returns

    { "valid": false, "region": "BJ", "type": null, "e164": "+22997123456", "reason": "not in a numbering plan: no prefix of that country's plan matches" }

    What it concludes

    Refused

    not in a numbering plan: no prefix of that country's plan matches

    Here it breaksThis number was ordinary before Benin moved to ten digits; the plan changed, and the library refuses it. That is this level’s real cost: the table is dated, and it is updated like any dependency.

  6. The same Benin number in its current form

    Given

    +229 01 97 12 34 56

    What it returns

    { "valid": true, "region": "BJ", "type": "MOBILE", "e164": "+2290197123456", "reason": null }

    What it concludes

    Valid number — BJ +2290197123456

    A mobile line: a text message will arrive.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • Depends on a library
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the number is checked inside your own process and is sent to nobody
  • A phone number is personal data: validating it locally discloses nothing, sending it to an online verification service discloses it

Breaking point

A numbering plan is dated data, not a rule: +229 97 12 34 56 was an ordinary Benin number before the country moved to ten digits, and the library refuses it today — a prefix opened after the installed release is refused the same way, until the update lands. The witness is in the same test: +229 01 97 12 34 56, the current form, passes.

When to move up a level

There is no level above this one. The day you need to know the line is actually allocated and reachable, or which operator holds it after portability, the answer is a billed network lookup at a carrier, and it takes the number out of your infrastructure.

The four options

  1. RecommendedN0libphonenumber’s numbering plans, with the region declared by the callerFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

There is nothing to learn, and the argument turns against the model. What a classifier trained on labelled numbers would approximate is a table published by regulators — and that table’s only flaw, that it ages, would get worse: a library updates with one command, a trained model has to be retrained and redeployed.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

The same argument, with a service to keep running on top, and one objection specific to this need: validating a number is a form check, called on every keystroke or every submission. What is wanted there is an immediate, replayable answer, not a score that needs a model in memory and an inference per call.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

The one thing a general-purpose model would add is guessing the missing region from context — the address, the language, the rest of the form. That is exactly what this entry refuses: an assumed region files a Belgian number under the French plan, the output is well-formed, and nobody sees the mistake. Its knowledge of the plans is also frozen at training time, with no way for it to say as of when, where a library states its version.

Further reading

Where the figures come from

Metadata