ProvenPin Install

Pydantic v2 email and phone validation with email-validator and phonenumbers: working versions (tested 2026-10-15)

Validate emails with EmailStr and normalize phone numbers to E164 with phonenumbers inside a Pydantic model, tested offline with no network lookups.

passed5 passed · 0 failed2026-10-15 13:24 UTC
Status
passed
Last passed
2026-10-15 13:24 UTC
Tests
5 / 5
Language
Python
Runtime
Python 3.13.16
pinned versions4
pydantic@2.14.1email-validator@2.3.0phonenumbers@9.0.41pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38741369695

The code

Exactly the files that passed. Your agent can pull them with get_recipe("pydantic-email-phone-validation") (install), or see them on GitHub.

requirements.txt
pydantic==2.14.1
email-validator==2.3.0
phonenumbers==9.0.41
pytest==9.1.1
models.py
from typing import Annotated

import phonenumbers
from phonenumbers import PhoneNumberFormat
from pydantic import AfterValidator, BaseModel, EmailStr


def _normalize_phone(value: str) -> str:
    try:
        # No default region: numbers must include a country code.
        num = phonenumbers.parse(value, None)
    except phonenumbers.NumberParseException as exc:
        # NumberParseException is not a ValueError, so pydantic would not
        # wrap it in ValidationError. Re-raise as ValueError.
        raise ValueError(str(exc)) from exc
    if not phonenumbers.is_valid_number(num):
        raise ValueError("invalid phone number")
    return phonenumbers.format_number(num, PhoneNumberFormat.E164)


Phone = Annotated[str, AfterValidator(_normalize_phone)]


class Contact(BaseModel):
    email: EmailStr
    phone: Phone
tests/test_models.py
import pytest
from pydantic import ValidationError

from models import Contact


def test_normalizes_phone_to_e164():
    c = Contact(email="ada@example.com", phone="+1 650 253 0000")
    assert c.phone == "+16502530000"


def test_email_requires_dotted_domain():
    # email-validator rejects domains without a period, e.g. "localhost".
    with pytest.raises(ValidationError):
        Contact(email="ada@localhost", phone="+1 650 253 0000")


def test_unparseable_phone_is_validation_error():
    # phonenumbers raises NumberParseException, which must surface as ValidationError.
    with pytest.raises(ValidationError) as info:
        Contact(email="ada@example.com", phone="call me")
    assert "phone" in str(info.value)


def test_national_number_without_region_rejected():
    with pytest.raises(ValidationError):
        Contact(email="ada@example.com", phone="650 253 0000")


def test_invalid_phone_rejected():
    with pytest.raises(ValidationError):
        Contact(email="ada@example.com", phone="+1 650 253 000")
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.