Validate phone numbers with Pydantic
Phone number validation in Pydantic that knows the national numbering plans: what people type is read in the plans you pick, a number no plan accepts gets Pydantic's usual error, and what you store is its E.164 form. The reading is done by one file with no dependency, from the Python guide; the rest is Pydantic's own validation hook, a few lines long.
1. The file
Take phone.py from the Python guide, with the plans of the countries you serve (the link opens it for the United Kingdom, the United States and France, the plans the tests below use). It needs Python 3.9 or later; standard library only. Its parse reads a number as typed and returns the number with its E.164 form, region and line type, or None when no plan accepts it.
pip install pydantic2. The Pydantic side
phone_types.py
The type: the language guide's parse as an after-validator.
"""Phone numbers as Pydantic types, read by phone.py in its plans."""
from __future__ import annotations
from typing import Annotated, Callable
from pydantic import AfterValidator
import phone
def e164_in(default_region: str | None = None) -> Callable[[str], str]:
"""Reads what was typed (national numbers in default_region) and returns its E.164 form."""
def read(text: str) -> str:
number = phone.parse(text, default_region)
if number is None:
raise ValueError("not a valid phone number")
return number.e164
return read
PhoneNumberGB = Annotated[str, AfterValidator(e164_in("GB"))]models.py
Two models: one with a fixed default region, one that reads the number in the country sent with it.
from __future__ import annotations
from pydantic import BaseModel, model_validator
import phone
from phone_types import PhoneNumberGB
class Signup(BaseModel):
name: str
phone: PhoneNumberGB
class Contact(BaseModel):
"""The country comes with the number: read it there."""
country: str
phone: str
@model_validator(mode="after")
def read_phone(self) -> Contact:
number = phone.parse(self.phone, self.country)
if number is None:
raise ValueError(f"not a valid phone number in {self.country}")
self.phone = number.e164
return self3. The tests
test_models.py: The tests, with unittest.
import unittest
from pydantic import ValidationError
from models import Contact, Signup
class SignupTest(unittest.TestCase):
def test_national_number_in_the_default_region(self):
self.assertEqual(Signup(name="Ada", phone="07400 123456").phone, "+447400123456")
def test_international_number(self):
self.assertEqual(Signup(name="Ada", phone="+1 201-555-0123").phone, "+12015550123")
def test_refuses_what_no_plan_accepts(self):
with self.assertRaises(ValidationError) as caught:
Signup(name="Ada", phone="+44 7400 12345")
self.assertEqual(caught.exception.errors()[0]["loc"], ("phone",))
self.assertIn("not a valid phone number", caught.exception.errors()[0]["msg"])
class ContactTest(unittest.TestCase):
def test_reads_the_number_in_its_country(self):
self.assertEqual(Contact(country="FR", phone="06 12 34 56 78").phone, "+33612345678")
def test_the_same_digits_in_another_country(self):
with self.assertRaises(ValidationError):
Contact(country="US", phone="06 12 34 56 78")
if __name__ == "__main__":
unittest.main()python -m unittestRun before publishing with pydantic 2.14.1 and the file generated for GB, US, FR, as 2026-10-25.
What is specific to Pydantic
AfterValidatorruns once the value is astr; raisingValueErrormakes Pydantic report a validation error on the field, with the message after "Value error, ".- The field holds the E.164 form after validation, so
model_dump()gives what you store. - A model-level validator suits forms that send the country beside the number; it runs after the fields, so
countryis already a string. FastAPI uses these models as they are, and answers 422 with the error.