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.
In this guide
- 1. The problem with phone number validation
- 2. The naive approach: regex in Python
- 3. Why phone validation is genuinely hard
- 4. The right solution: one API call
- 5. Python code example
- 6. cURL examples
- 7. Understanding the response
- 8. Edge cases — country formats, leading zeros, toll-free numbers
- 9. Summary
- 10. See also
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:
| Input | Format |
|---|---|
| +48600123456 | E.164 (international, no spaces) |
| +48 600 123 456 | International with spaces |
| 600 123 456 | National (no country code) |
| 600123456 | National, compact |
| 0048600123456 | International with 00 prefix |
| (48) 600-123-456 | Mixed 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
+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.
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.
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.
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.
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.
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:
| Country | Code | Example (E.164) | National format | Digits |
|---|---|---|---|---|
| United States | +1 | +12025551234 | (202) 555-1234 | 10 |
| United Kingdom | +44 | +447911123456 | 07911 123456 | 10-11 |
| Poland | +48 | +48600123456 | 600 123 456 | 9 |
| Germany | +49 | +4915112345678 | 0151 12345678 | 10-11 |
| Japan | +81 | +819012345678 | 090-1234-5678 | 10-11 |
| Australia | +61 | +61412345678 | 0412 345 678 | 9 |
| India | +91 | +919876543210 | 09876 543210 | 10 |
| Brazil | +55 | +5511987654321 | (11) 98765-4321 | 10-11 |
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.
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")
+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 }
| Field | Type | Description |
|---|---|---|
| valid | boolean | Whether the phone number is valid for the detected (or specified) country |
| countryCode | string | ISO 3166-1 alpha-2 country code (e.g., "PL", "US", "GB") |
| callingCode | string | ITU-T country calling code without the + prefix (e.g., "48", "1", "44") |
| nationalNumber | string | The phone number without the country calling code, no formatting |
| type | string | Number type: MOBILE, FIXED_LINE, FIXED_LINE_OR_MOBILE, TOLL_FREE, PREMIUM_RATE, SHARED_COST, VOIP, PERSONAL_NUMBER, etc. |
| e164 | string | The number in E.164 format — the globally unique canonical form (e.g., +48600123456) |
| national | string | The number formatted for national dialling (e.g., "600 123 456") |
| international | string | The 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
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
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.