ProvenPin Install

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
pinned versions2
pydantic@2.14.1pytest@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 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.

requirements.txt
pydantic==2.14.1
pytest==9.1.1
models.py
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
tests/test_validators.py
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.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.