Guide · Python · SDK · REST API

Phone Number Validation in Python — Beyond Regex

A regex can match digits and dashes, but it cannot tell you whether a number is actually valid for a given country, whether it is mobile or landline, or what the correct E.164 format is. Here's the full picture — with Python code you can drop into any project.

1. The problem with phone number validation

Phone numbers look simple on the surface — just digits with some optional formatting characters. But the reality is that every country has its own numbering plan, its own rules for valid lengths, area codes, mobile prefixes, and formatting conventions. A number that is perfectly valid in one country may be completely invalid in another, even if it has the same number of digits.

Consider a simple input field that asks for a phone number. Users might enter any of these for the same Polish mobile number:

InputFormat
+48600123456E.164 (international, no spaces)
+48 600 123 456International with spaces
600 123 456National (no country code)
600123456National, compact
0048600123456International with 00 prefix
(48) 600-123-456Mixed formatting with parentheses

All of these refer to the same number. A proper phone validation solution needs to understand all these formats, normalise them to a canonical representation, and verify that the resulting number is actually valid for the claimed country.


2. The naive approach: regex in Python

The most common first attempt is to write a regex that accepts "phone-like" strings. This approach is fundamentally flawed because phone numbering rules are not regular — they depend on country-specific logic that a single regex cannot capture.

import re

# ❌ Too loose — accepts anything that looks like digits
LOOSE_REGEX = re.compile(r'^[\d\s\-\+\(\)]+$')

print(LOOSE_REGEX.match('+48600123456'))   # Match — valid
print(LOOSE_REGEX.match('123'))            # Match — too short to be real
print(LOOSE_REGEX.match('+999999999999'))  # Match — no such country code
print(LOOSE_REGEX.match('((('))            # Match — just parentheses!

# ❌ Too strict — rejects legitimate formats
STRICT_REGEX = re.compile(r'^\+\d{10,15}$')

print(STRICT_REGEX.match('+48600123456'))   # Match — works
print(STRICT_REGEX.match('600 123 456'))    # None — national format rejected
print(STRICT_REGEX.match('+1 555 0100'))    # None — spaces rejected
print(STRICT_REGEX.match('+44 20 7946 0958'))  # None — UK number rejected
⚠️A regex can tell you whether a string contains digits and formatting characters, but it cannot tell you whether those digits form a valid phone number for any specific country. Country code +44 (UK) has different length rules than +1 (US/Canada) or +48 (Poland).

3. Why phone validation is genuinely hard

Phone number validation is not a single check — it requires understanding a complex, country-specific set of rules that changes over time as numbering plans evolve.

1

Country calling codes are not uniform

Country codes range from 1 digit (+1 for US/Canada) to 3 digits (+420 for Czech Republic). The same digit sequence can be a valid country code or a prefix of another.

2

Number lengths vary by country and type

US numbers are always 10 digits (after country code). UK numbers can be 10 or 11 digits. German numbers range from 3 to 15 digits depending on area code and type.

3

Number types have different rules

Mobile numbers, landlines, toll-free numbers, premium-rate numbers, and shared-cost numbers all follow different prefix and length patterns within the same country.

4

National vs. international format

In many countries, national dialling requires a trunk prefix (0 in most of Europe, 1 in some countries). This prefix must be stripped when converting to international format.

5

Numbering plans change

Countries periodically restructure their numbering plans — merging area codes, changing mobile prefixes, or adding new number ranges. Static regex patterns go stale.

Here is how phone number formats differ across countries — same purpose (mobile number), completely different rules:

CountryCodeExample (E.164)National formatDigits
United States+1+12025551234(202) 555-123410
United Kingdom+44+44791112345607911 12345610-11
Poland+48+48600123456600 123 4569
Germany+49+49151123456780151 1234567810-11
Japan+81+819012345678090-1234-567810-11
Australia+61+614123456780412 345 6789
India+91+91987654321009876 54321010
Brazil+55+5511987654321(11) 98765-432110-11
ℹ️Google's libphonenumber library is the gold standard for local phone parsing, but it adds a large dependency and still requires you to handle updates as numbering plans change. The IsValid Phone API wraps this complexity in a single HTTP call with always up-to-date rules.

4. The right solution: one API call

The IsValid Phone API validates phone numbers against the full international numbering plan database. It parses the input, identifies the country, determines the number type (mobile, landline, toll-free, etc.), and returns the number in E.164, national, and international formats — all in a single request.

<20ms
Validation
full parse + type detection
200+
Countries
all ITU-T numbering plans
100/day
Free tier
no credit card

Pass any phone number string — with or without country code, with or without formatting — and optionally provide a countryCode hint for national-format numbers. The API returns the validated, parsed result.

Full parameter reference and response schema: Phone Validation API docs →


5. Python code example

Using the isvalid-sdk Python SDK or the popular requests library. Install with pip install isvalid-sdk or pip install requests.

# phone_validator.py
import os
from isvalid_sdk import IsValidConfig, create_client

iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"]))

# ── Validate with full international number ──────────────────────────────────

result = iv.phone("+48600123456")
if result["valid"]:
    print(f"Country: {result['countryCode']}")     # PL
    print(f"Type: {result['type']}")               # MOBILE
    print(f"E.164: {result['e164']}")              # +48600123456
    print(f"National: {result['national']}")       # 600 123 456
    print(f"International: {result['international']}")  # +48 600 123 456

# ── Validate national format with country hint ───────────────────────────────

result = iv.phone("600123456", country_code="PL")
print(result["valid"])        # True
print(result["e164"])         # +48600123456
print(result["callingCode"])  # 48

# ── Invalid number ───────────────────────────────────────────────────────────

invalid = iv.phone("+48123")
print(invalid["valid"])  # False

In a Flask application — validate phone numbers in a registration or contact form endpoint:

# app.py (Flask)
import os
from flask import Flask, request, jsonify
from isvalid_sdk import IsValidConfig, create_client

app = Flask(__name__)
iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"]))


@app.post("/api/validate-phone")
def validate_phone_endpoint():
    """Validate a phone number and return parsed details."""
    body = request.get_json(silent=True) or {}
    phone = body.get("phone", "").strip()
    country = body.get("countryCode")

    if not phone:
        return jsonify({"error": "Phone number is required"}), 400

    try:
        if country:
            result = iv.phone(phone, country_code=country)
        else:
            result = iv.phone(phone)
    except Exception as exc:
        app.logger.error("Phone validation failed: %s", exc)
        return jsonify({"error": "Validation service unavailable"}), 502

    if not result["valid"]:
        return jsonify({
            "error": "Invalid phone number. Please check the number and try again.",
        }), 400

    return jsonify({
        "valid": True,
        "e164": result["e164"],
        "national": result["national"],
        "international": result["international"],
        "countryCode": result["countryCode"],
        "type": result["type"],
    })


@app.post("/api/register")
def register():
    """Registration endpoint with phone validation."""
    body = request.get_json(silent=True) or {}
    phone = body.get("phone", "").strip()
    name = body.get("name", "").strip()

    if not phone or not name:
        return jsonify({"error": "Name and phone are required"}), 400

    try:
        result = iv.phone(phone)
    except Exception:
        return jsonify({"error": "Phone validation service unavailable"}), 502

    if not result["valid"]:
        return jsonify({"error": "Invalid phone number"}), 400

    # Only accept mobile numbers for SMS verification
    if result.get("type") != "MOBILE":
        return jsonify({
            "error": "Please provide a mobile number for SMS verification.",
            "detectedType": result.get("type"),
        }), 400

    # Store the E.164-normalised number
    create_user(name=name, phone=result["e164"])
    return jsonify({"success": True, "phone": result["e164"]})

Batch validation — validate a list of phone numbers from a CSV file:

# batch_validate.py
import csv
import os
import sys
from isvalid_sdk import IsValidConfig, create_client

iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"]))


def validate_csv(input_path: str, output_path: str) -> None:
    """Read phone numbers from a CSV, validate each, write results."""
    with open(input_path, newline="") as infile, \
         open(output_path, "w", newline="") as outfile:

        reader = csv.DictReader(infile)
        fieldnames = [
            "original", "valid", "e164", "country",
            "type", "national", "international",
        ]
        writer = csv.DictWriter(outfile, fieldnames=fieldnames)
        writer.writeheader()

        for row in reader:
            phone = row.get("phone", "").strip()
            if not phone:
                continue

            try:
                result = iv.phone(phone)
            except Exception as exc:
                print(f"Error validating {phone}: {exc}", file=sys.stderr)
                writer.writerow({"original": phone, "valid": False})
                continue

            writer.writerow({
                "original": phone,
                "valid": result["valid"],
                "e164": result.get("e164", ""),
                "country": result.get("countryCode", ""),
                "type": result.get("type", ""),
                "national": result.get("national", ""),
                "international": result.get("international", ""),
            })


if __name__ == "__main__":
    validate_csv("contacts.csv", "validated_contacts.csv")
    print("Done. Results written to validated_contacts.csv")
Always store phone numbers in E.164 format (+48600123456) in your database. It is the only format that is globally unique and unambiguous. Display the number in national or international format based on the user's locale.

6. cURL examples

Validate a phone number with the full international prefix:

curl -G -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "value=+48600123456" \
  "https://api.isvalid.dev/v0/phone"

Validate a national-format number with a country code hint:

curl -G -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "value=600123456" \
  --data-urlencode "countryCode=PL" \
  "https://api.isvalid.dev/v0/phone"

Validate a US number:

curl -G -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "value=+12025551234" \
  "https://api.isvalid.dev/v0/phone"

Test with an invalid number:

curl -G -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "value=+48123" \
  "https://api.isvalid.dev/v0/phone"

7. Understanding the response

Valid Polish mobile number:

{
  "valid": true,
  "countryCode": "PL",
  "callingCode": "48",
  "nationalNumber": "600123456",
  "type": "MOBILE",
  "e164": "+48600123456",
  "national": "600 123 456",
  "international": "+48 600 123 456"
}

Valid US landline number:

{
  "valid": true,
  "countryCode": "US",
  "callingCode": "1",
  "nationalNumber": "2025551234",
  "type": "FIXED_LINE_OR_MOBILE",
  "e164": "+12025551234",
  "national": "(202) 555-1234",
  "international": "+1 202-555-1234"
}

Valid UK mobile number:

{
  "valid": true,
  "countryCode": "GB",
  "callingCode": "44",
  "nationalNumber": "7911123456",
  "type": "MOBILE",
  "e164": "+447911123456",
  "national": "07911 123456",
  "international": "+44 7911 123456"
}

Invalid number (too short):

{
  "valid": false
}
FieldTypeDescription
validbooleanWhether the phone number is valid for the detected (or specified) country
countryCodestringISO 3166-1 alpha-2 country code (e.g., "PL", "US", "GB")
callingCodestringITU-T country calling code without the + prefix (e.g., "48", "1", "44")
nationalNumberstringThe phone number without the country calling code, no formatting
typestringNumber type: MOBILE, FIXED_LINE, FIXED_LINE_OR_MOBILE, TOLL_FREE, PREMIUM_RATE, SHARED_COST, VOIP, PERSONAL_NUMBER, etc.
e164stringThe number in E.164 format — the globally unique canonical form (e.g., +48600123456)
nationalstringThe number formatted for national dialling (e.g., "600 123 456")
internationalstringThe number formatted for international dialling (e.g., "+48 600 123 456")

8. Edge cases — country formats, leading zeros, toll-free numbers

National format without country code

When users enter a phone number without an international prefix (e.g., "600 123 456" instead of "+48 600 123 456"), the API cannot determine the country from the number alone. Always pass the countryCode parameter when you know the user's country — from their IP, locale, or a country dropdown in your form.

# Without country hint — ambiguous, may fail
result = iv.phone("600123456")
# valid: False — cannot determine country

# With country hint — unambiguous
result = iv.phone("600123456", country_code="PL")
# valid: True, e164: "+48600123456", type: "MOBILE"

Leading zeros and trunk prefixes

Many countries use a trunk prefix (typically "0") for national dialling. When converting to E.164, this prefix must be stripped. The IsValid API handles this automatically — you do not need to manually strip leading zeros.

# UK mobile with trunk prefix "0"
result = iv.phone("07911123456", country_code="GB")
print(result["e164"])           # +447911123456
print(result["nationalNumber"]) # 7911123456 — trunk prefix stripped

# German number with 0 prefix
result = iv.phone("015112345678", country_code="DE")
print(result["e164"])           # +4915112345678

# Italian numbers — leading 0 is part of the number (special case!)
result = iv.phone("0212345678", country_code="IT")
print(result["e164"])           # +390212345678 — 0 is preserved
⚠️Italy is a notable exception — the leading zero is part of the actual subscriber number for landlines, not a trunk prefix. This is why manual zero-stripping logic breaks for Italian numbers. Let the API handle it.

Toll-free and premium-rate numbers

Toll-free numbers (e.g., 1-800 in the US, 0800 in the UK) and premium-rate numbers (e.g., 1-900, 0900) are valid phone numbers but you may want to handle them differently in your application. The type field in the API response lets you distinguish them.

result = iv.phone("+18005551234")
print(result["type"])  # TOLL_FREE

result = iv.phone("+449001234567")
print(result["type"])  # PREMIUM_RATE

# Example: only accept mobile numbers for SMS verification
def is_sms_capable(phone: str, country_code: str | None = None) -> bool:
    """Check if a phone number can receive SMS."""
    if country_code:
        result = iv.phone(phone, country_code=country_code)
    else:
        result = iv.phone(phone)

    if not result["valid"]:
        return False

    # MOBILE and FIXED_LINE_OR_MOBILE can typically receive SMS
    return result["type"] in ("MOBILE", "FIXED_LINE_OR_MOBILE")

Numbers with formatting characters

Users often enter phone numbers with spaces, dashes, parentheses, or dots. While you could strip these before sending to the API, the IsValid API accepts numbers with common formatting characters and handles the cleanup internally.

# All of these produce the same result:
iv.phone("+48600123456")       # Compact E.164
iv.phone("+48 600 123 456")    # Spaces
iv.phone("+48-600-123-456")    # Dashes
iv.phone("+48 (600) 123-456")  # Mixed formatting

# If you prefer to clean up first:
import re

def normalize_input(phone: str) -> str:
    """Strip common formatting characters, keep + and digits."""
    return re.sub(r"[^\d+]", "", phone)

print(normalize_input("+48 (600) 123-456"))  # +48600123456

NANP numbers (US, Canada, Caribbean)

The North American Numbering Plan (NANP) covers the US, Canada, and 20+ Caribbean nations — all sharing country code +1. The API correctly identifies the specific country/territory based on the area code (NPA).

# US number
result = iv.phone("+12025551234")
print(result["countryCode"])  # US

# Canadian number (area code 416 = Toronto)
result = iv.phone("+14165551234")
print(result["countryCode"])  # CA

# Puerto Rico (area code 787)
result = iv.phone("+17875551234")
print(result["countryCode"])  # PR

E.164 as the canonical storage format

E.164 is the ITU-T recommendation that defines the international numbering plan. An E.164 number starts with a + sign, followed by the country calling code and the subscriber number, with no spaces or formatting — up to 15 digits total. Always store phone numbers in E.164 format in your database. It is globally unique, unambiguous, and works with every telephony API (Twilio, Vonage, AWS SNS, etc.).


9. Summary

Do not rely on regex for phone validation — country rules are too complex and change over time
Do not strip leading zeros manually — some countries (Italy) include them in the subscriber number
Use the IsValid Phone API to validate, parse, and format in one call
Always store numbers in E.164 format — it is globally unique and unambiguous
Pass a countryCode hint when accepting national-format numbers without a + prefix
Check the type field to filter mobile-only for SMS verification flows

See also

Validate phone numbers instantly

Free tier includes 100 API calls per day. No credit card required. Full parse, type detection, and E.164 normalisation for 200+ countries in under 20ms.