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:32 UTC- Status
- passed
- Last passed
- 2026-10-15 13:32 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
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.
pydantic==2.14.1 email-validator==2.3.0 phonenumbers==9.0.41 pytest==9.1.1
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
import pytest
from pydantic import ValidationError
from models import Contact
def test_normalizes_phone_to_e164():
c = Contact(email="[email protected]", 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="[email protected]", phone="call me")
assert "phone" in str(info.value)
def test_national_number_without_region_rejected():
with pytest.raises(ValidationError):
Contact(email="[email protected]", phone="650 253 0000")
def test_invalid_phone_rejected():
with pytest.raises(ValidationError):
Contact(email="[email protected]", phone="+1 650 253 000")
[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.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI + Pydantic v2 validators, discriminated unions and serializers
Pydantic v2 patterns in FastAPI that agents often get wrong: field_validator, model_validator, discriminated unions, computed_field and response_model filtering.
FastAPI background tasks + dependency overrides
FastAPI BackgroundTasks for post-response work, with dependency_overrides to swap services in tests.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
httpx async API client + respx mocks
A typed async API client on httpx with retries on 5xx and Pydantic parsing, tested offline with respx route mocks.
All Pydantic recipesAll email-validator recipesAll phonenumbers recipes