Skip to content

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 pydantic

2. 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 self

3. 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 unittest

Run before publishing with pydantic 2.14.1 and the file generated for GB, US, FR, as 2026-10-25.

What is specific to Pydantic

  • AfterValidator runs once the value is a str; raising ValueError makes 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 country is already a string. FastAPI uses these models as they are, and answers 422 with the error.