Pydantic v2 Annotated validators (Before and After): working versions (tested 2026-10-16)
Reusable BeforeValidator and AfterValidator types that coerce, constrain and normalize values, tested offline, including the defaults-are-not-validated gotcha.
passed6 passed · 0 failed2026-10-16 00:01 UTC- Status
- passed
- Last passed
- 2026-10-16 00:01 UTC
- Tests
- 6 / 6
- 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 38826982604
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pydantic-annotated-after-validator") (install), or see them on GitHub.
pydantic==2.14.1 pytest==9.1.1
from datetime import datetime, timezone
from typing import Annotated
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
)
def unformat_number(v: object) -> object:
if isinstance(v, str):
return v.replace("$", "").replace(",", "").strip()
return v
def reject_negative(v: float) -> float:
if v < 0:
raise ValueError("must be non-negative")
return v
def as_utc(v: datetime) -> datetime:
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v.astimezone(timezone.utc)
def lower_all(v: list[str]) -> list[str]:
return [t.lower() for t in v]
def append_a(v: list[str]) -> list[str]:
return [*v, "a"]
def append_b(v: list[str]) -> list[str]:
return [*v, "b"]
Money = Annotated[float, BeforeValidator(unformat_number), AfterValidator(reject_negative)]
Tags = Annotated[list[str], AfterValidator(lower_all)]
Ordered = Annotated[list[str], AfterValidator(append_a), AfterValidator(append_b)]
class Product(BaseModel):
name: str
price: Money
updated_at: Annotated[datetime, AfterValidator(as_utc)]
tags: Tags = Field(default_factory=list)
class LazySettings(BaseModel):
retries: Annotated[int, AfterValidator(lambda v: v + 1)] = 3
class StrictSettings(BaseModel):
model_config = ConfigDict(validate_default=True)
retries: Annotated[int, AfterValidator(lambda v: v + 1)] = 3
from datetime import datetime, timezone
import pytest
from pydantic import TypeAdapter, ValidationError
from models import LazySettings, Money, Ordered, Product, StrictSettings
def product(**over):
data = {"name": "widget", "price": 1, "updated_at": datetime(2024, 1, 1, 12, 0)}
data.update(over)
return Product(**data)
def test_before_then_after_validators():
assert product(price="$1,200.50").price == 1200.5
def test_after_error_is_reported_on_the_field():
with pytest.raises(ValidationError) as exc:
product(price=-5)
err = exc.value.errors()[0]
assert err["loc"] == ("price",) and "non-negative" in err["msg"]
def test_after_validator_normalizes_naive_datetime():
assert product().updated_at == datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
aware = product(updated_at=datetime(2024, 1, 1, 17, 0, tzinfo=timezone.utc))
assert aware.updated_at.tzinfo is timezone.utc
def test_annotated_type_is_reusable_standalone():
ta = TypeAdapter(Money)
assert ta.validate_python("$10") == 10.0
with pytest.raises(ValidationError):
ta.validate_python("-3")
def test_after_validators_run_left_to_right():
assert TypeAdapter(Ordered).validate_python(["x"]) == ["x", "a", "b"]
def test_defaults_are_not_validated_unless_configured():
assert LazySettings().retries == 3
assert StrictSettings().retries == 4
[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
beartype conf + pydantic model_validate runtime checks
Pair beartype boundary checks with pydantic model_validate, using BeartypeConf to align int/float coercion and route violations, and pin down the gotchas where the two guards disagree.
DeepDiff diffing of pydantic v2 model dumps with ignore_order
Compare pydantic v2 model dumps with DeepDiff, normalizing python vs json dump modes and taming list order, datetime and float gotchas.
Faker and polyfactory seeded Pydantic model factories offline
Define Pydantic models and generate fixtures offline with polyfactory, binding one Faker instance so Use() providers and seed_random stay reproducible.
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.