# Pydantic advanced-types / custom-validators practical guide: make reusable 'domain types' with Annotated

> Faithful to the Pydantic v2 official documentation, this explains in real code advanced validation that designs reusable domain types — the Annotated pattern of AfterValidator/BeforeValidator/WrapValidator/PlainValidator, the constraint types of StringConstraints and annotated_types, custom types via __get_pydantic_core_schema__, discriminated unions, RootModel, and generic models.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, Pydantic, 型安全, バリデーション, データモデリング, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/pydantic-custom-types-annotated-validators-advanced-guide
- Category: Pydantic & type-safe validation
- Pillar guide: https://tomodahinata.com/en/blog/pydantic-v2-production-validation-type-safety

## Key points

- Baking validation into a 'type' with the Annotated pattern (AfterValidator, etc.) makes a reusable domain type like Username=Annotated[str, ...] and erases the duplication of @field_validator.
- The execution order of multiple validators is as the official defines: before/wrap is right→left, after is left→right. It doesn't match the visual order.
- Write constraints with Annotated+StringConstraints/annotated_types, not constr/conlist. constr is scheduled to be deprecated in Pydantic 3.0.
- With __get_pydantic_core_schema__, you can embed validation into a custom type that inherits str/int, and control the strength of validation with InstanceOf/SkipValidation.
- Express polymorphic data with a discriminated union, root-list/dict data with RootModel, and a general-purpose wrapper with Generic[T], making invalid states unrepresentable by type.

---

## **Introduction: validation logic "scatters"**

In the [Pydantic v2 practical guide](/blog/pydantic-v2-production-validation-type-safety), I handled the basics of `@field_validator` and `@model_validator`. With those you can write most validation. But as models increase in practice, a certain smell starts to drift.

- You're **copy-pasting the same `@field_validator`** for username normalization (trim whitespace, lowercase, length check) in each of the `User`, `Profile`, and `Comment` models.
- **Business value types** like "a positive amount," "a Japanese postal code," "an inventory count" are scattered as mere `int` / `str`, and the constraints are buried inside the model definitions.
- Even for the same validation, the place to write it (decorator vs. `Field` constraint) is scattered per model, and you can't tell where to fix.

This is a sign that DRY (don't repeat) and SRP (single responsibility) have collapsed. This article's theme is, centered on Pydantic v2's **`Annotated` pattern,** to **promote validation logic into "reusable domain types."** If you make "email address," "username," and "positive integer" into **types defined once and reusable everywhere,** validation doesn't scatter, and changes consolidate into one point (the practice of ETC = Easy To Change in CLAUDE.md's words).

While being faithful to the [official documentation](https://pydantic.dev/docs/validation/latest/concepts/validators/), one level clearer than it, I systematize in real code the `Annotated` validators, constraint types, custom types, discriminated unions, `RootModel`, and generic models.

---

## **1. `Annotated` validators: bake validation into a "type"**

v2 has two ways to write validation. The `@field_validator` decorator (tied to a model), and the **`Annotated` pattern (tied to a type).** The essence of the latter is being able to **embed "this type is validated like this" into the type's definition itself.**

```python
from typing import Annotated
from pydantic import AfterValidator, BaseModel


def is_even(value: int) -> int:
    if value % 2 == 1:
        raise ValueError(f"{value} は偶数ではありません")
    return value


class Model(BaseModel):
    number: Annotated[int, AfterValidator(is_even)]
```

`AfterValidator(is_even)` runs `is_even` **after** `int`'s standard validation **finishes.** The point is being able to **carve this out as a named type.**

```python
from typing import Annotated
from pydantic import AfterValidator, BaseModel


def _normalize_username(value: str) -> str:
    cleaned = value.strip().lower()
    if not 3 <= len(cleaned) <= 30:
        raise ValueError("ユーザー名は3〜30文字です")
    return cleaned


# ✅ 再利用可能な「ドメインの型」を一度だけ定義する
Username = Annotated[str, AfterValidator(_normalize_username)]


class User(BaseModel):
    name: Username


class Comment(BaseModel):
    author: Username      # 同じ検証を、コピペせず再利用
    body: str
```

The official also clearly states *"one of the main benefits of using the annotation pattern is being able to reuse validators."* Instead of copy-pasting `@field_validator` into 3 models, use `Username` in 3 places. You get the **single source of truth** of validation logic.

### **H3: The 4 modes — `After` / `Before` / `Plain` / `Wrap`**

There are 4 kinds of validators you can put in `Annotated`. Each differs in "where it runs relative to the standard validation."

| Validator | When it runs | The value it receives | Main use |
| --- | --- | --- | --- |
| `AfterValidator` | **after** standard validation | a type-guaranteed value | validation, normalization (first choice) |
| `BeforeValidator` | **before** standard validation | raw input (`Any`) | pre-shaping the input format |
| `PlainValidator` | **instead of** standard validation | raw input (`Any`) | fully implement the validation logic yourself |
| `WrapValidator` | **wraps** standard validation | `Any` + `handler` | exception catching, fallback |

```python
from typing import Annotated, Any
from pydantic import BaseModel, BeforeValidator


def ensure_list(value: Any) -> Any:
    # "a,b,c" のような単一文字列も、検証前にリストへ整形する
    if isinstance(value, str):
        return [t.strip() for t in value.split(",")]
    return value


class Article(BaseModel):
    tags: Annotated[list[str], BeforeValidator(ensure_list)]
```

> ⚠️ **`PlainValidator` cuts off validation**: since `PlainValidator` runs "instead of" standard validation, the subsequent type validation isn't done. Even if you write `Annotated[int, PlainValidator(f)]`, it's **not guaranteed** that the value `f` returned is an `int` — you need to guarantee the type at `f`'s responsibility. From both performance and safety, consider `AfterValidator` first.

### **H3: Execution order — "the visual order" and "the actual order" differ**

When you stack multiple validators, their execution order is counterintuitive. The official's definition is clear.

> *before and wrap validators are run from right to left, and after validators are then run from left to right.*

```python
from typing import Annotated
from pydantic import AfterValidator, BaseModel, BeforeValidator, WrapValidator


class Model(BaseModel):
    name: Annotated[
        str,
        AfterValidator(runs_3rd),   # after：左→右
        AfterValidator(runs_4th),
        BeforeValidator(runs_2nd),  # before/wrap：右→左
        WrapValidator(runs_1st),
    ]
# 実行順：runs_1st → runs_2nd → runs_3rd → runs_4th
```

**`before` / `wrap` are right to left, `after` is left to right.** Within the same list, the directions are reversed. Since it doesn't match the visual top-to-bottom order, be conscious of the order when stacking multiple.

---

## **2. Constraint types: write with `Annotated` + constraints, not `constr`**

Constraints like "3–30 characters," "a positive integer," "a regex match" can be written in 3 ways. **Which to choose** is the point.

```python
from decimal import Decimal
from typing import Annotated
from annotated_types import Gt, Len
from pydantic import BaseModel, Field, StringConstraints


class Product(BaseModel):
    # ① Field メタデータで制約（最も一般的）
    price: int = Field(gt=0)
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    amount: Decimal = Field(max_digits=7, decimal_places=2)

    # ② annotated_types の制約クラス（再利用しやすい）
    quantity: Annotated[int, Gt(0)]
    tags: Annotated[list[str], Len(max_length=10)]

    # ③ StringConstraints（文字列の整形＋制約をまとめて）
    code: Annotated[str, StringConstraints(
        strip_whitespace=True, to_upper=True, pattern=r"^[A-Z]+$"
    )]
```

Using `annotated_types`'s `Gt(0)` and `Len(...)`, you can make a **reusable constraint type** in the same way as chapter 1.

```python
from typing import Annotated
from annotated_types import Gt

PositiveInt = Annotated[int, Gt(0)]   # プロジェクト全体で使い回せる
```

> ⚠️ **Don't use `constr` / `conlist`**: the v1-derived "functions that return a constrained type" like `constr(min_length=3)` and `conlist(...)` still work. But the official clearly states about `constr` that *"this function recommends and prefers the use of `Annotated` + `StringConstraints`, and is deprecated. **It will become deprecated in Pydantic 3.0.**"* In new code, always write in the `Annotated[str, StringConstraints(...)]` form and avoid future breaking changes.

**Why is the `Annotated` form superior?**
`constr(...)`, from the view of a type checker (mypy / Pyright), is "a function that returns a type at runtime," with poor compatibility with static analysis. On the other hand, `Annotated[str, ...]` is **statically treated straightforwardly as `str`,** with the additional information (constraint) just riding alongside as metadata. **`str` to the type checker, a constrained `str` to Pydantic** — this best-of-both holds.

---

## **3. Custom types: embed validation into a custom type with `__get_pydantic_core_schema__`**

`Annotated` validators were a mechanism to "add validation to an existing type." Going a step further, you can also **build the validation logic into a custom class itself that inherits `str` or `int`.** This is `__get_pydantic_core_schema__`.

```python
from typing import Any
from pydantic_core import CoreSchema, core_schema
from pydantic import GetCoreSchemaHandler, TypeAdapter


class Username(str):
    """検証込みのユーザー名型。str を継承しているのでそのまま文字列として使える。"""

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        # handler(str) で「まず str として検証」、その後に cls を適用する
        return core_schema.no_info_after_validator_function(cls, handler(str))


ta = TypeAdapter(Username)
result = ta.validate_python("alice")
print(type(result))  # <class '__main__.Username'>
```

**`handler(str)` is the crux.** Rather than assembling the base schema (here, `str`'s validation) yourself, have `handler` make it. On that, declare "apply `cls` after validation" with `no_info_after_validator_function`.

### **H3: A reusable marker class — "your own `AfterValidator`"**

If you make a **marker class** with `__get_pydantic_core_schema__`, you can mass-produce your own validation annotations that ride on `Annotated`.

```python
from dataclasses import dataclass
from typing import Annotated, Any, Callable
from pydantic_core import CoreSchema, core_schema
from pydantic import BaseModel, GetCoreSchemaHandler


@dataclass(frozen=True)
class MyAfterValidator:
    func: Callable[[Any], Any]

    def __get_pydantic_core_schema__(
        self, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        return core_schema.no_info_after_validator_function(
            self.func, handler(source_type)
        )


# 標準ライブラリの関数すら、そのまま検証として使える
Lowercased = Annotated[str, MyAfterValidator(str.lower)]


class Account(BaseModel):
    email: Lowercased
```

### **H3: Switch the strength of validation — `InstanceOf` and `SkipValidation`**

```python
from pydantic import BaseModel, InstanceOf, SkipValidation


class Fruit:
    ...


class Basket(BaseModel):
    # InstanceOf：型強制せず「そのクラスのインスタンスか」だけを検証する
    fruits: list[InstanceOf[Fruit]]
    # SkipValidation：このフィールドの検証を意図的にスキップする
    raw_labels: list[SkipValidation[str]]
```

`InstanceOf[T]` is a type that "requires only `isinstance(v, T)` and doesn't coerce." It's effective when you want to receive an arbitrary Python object (an ORM entity, etc.) as-is. `SkipValidation[T]` is conversely a marker that "omits validation."

> ⚠️ **Place `SkipValidation` last**: per the official, since `SkipValidation` changes the schema to `any_schema` (= passes anything), stacking other annotations after it sometimes has no effect. It's stated that *"it should be the last annotation of the type it's applied to."* Since removing validation is a hole in type safety, limit its use.

---

## **4. Discriminated unions: safely model polymorphic data**

"Data that has different fields by kind" — events (`click` / `purchase` / `signup`), notifications (`email` / `sms` / `push`), shapes (`circle` / `rect`) — is, as a standard, expressed with a **discriminated union.**

```python
from typing import Literal, Union
from pydantic import BaseModel, Field


class EmailNotification(BaseModel):
    channel: Literal["email"]
    to_address: str
    subject: str


class SmsNotification(BaseModel):
    channel: Literal["sms"]
    phone_number: str


class PushNotification(BaseModel):
    channel: Literal["push"]
    device_token: str


class Envelope(BaseModel):
    # channel の値で、検証すべきメンバーを一意に確定する
    notification: Union[
        EmailNotification, SmsNotification, PushNotification
    ] = Field(discriminator="channel")


Envelope.model_validate(
    {"notification": {"channel": "sms", "phone_number": "090-0000-0000"}}
)
# → SmsNotification として検証される
```

Give each member **a common `Literal` discriminator field** and specify it with `Field(discriminator=...)`. When the member's discriminator key has a **different name,** or you want to **dispatch by type,** pass a discrimination function to `Discriminator` and label each member with `Tag` (there's a real example in chapter 3 of the [performance-optimization guide](/blog/pydantic-v2-performance-optimization-guide)).

**Why is this superior?**
A discriminated union is the typical design of **"making invalid states unrepresentable."** Trying to pass `subject` (for email) to `SmsNotification` errors, and if `channel="sms"` but `phone_number` is missing, only that omission is pointed out. It drives procedural branching like `if data["channel"] == "email": ...` out of the code and **has the type's structure itself speak the business rules.** Along with the generics and `model_validator` touched on in chapter 7, it's a core technique of domain modeling. Performance-wise too, it has the advantage of avoiding "brute force."

---

## **5. `RootModel`: type data whose root is `list` / `dict`**

An external API often returns, not an object, but **a top-level array or dictionary** (`["a", "b", "c"]` or `{"alice": 30, "bob": 25}`). Type such "root-is-a-collection" data with `RootModel` (the successor to v1's `__root__`).

```python
from pydantic import RootModel


Tags = RootModel[list[str]]
ScoreByName = RootModel[dict[str, int]]

tags = Tags(["python", "rust"])
print(tags.root)               # ['python', 'rust']  ← 中身は .root でアクセス
print(tags.model_dump_json())  # ["python","rust"]
```

For validation where the root element isn't a model, besides `RootModel`, you can also use `TypeAdapter(list[User])`. You can organize it as **`RootModel` if reusing (it has a name and you can add methods), `TypeAdapter` if one-off.**

> 💡 **Access with `.root`**: extract `RootModel`'s contents from the attribute `.root`. If you want to subscript like `tags[0]`, subclass it and define `__getitem__` / `__iter__` yourself. There's only one root type, and it can't be mixed with normal fields.

---

## **6. Generic models: make a reusable wrapper type-safely**

You want to reuse a **general-purpose response envelope** like "success flag + data + meta info" while changing the inner type — `Generic[T]` is the turn.

```python
from typing import Generic, TypeVar
from pydantic import BaseModel

DataT = TypeVar("DataT")


class ApiResponse(BaseModel, Generic[DataT]):
    success: bool
    data: DataT
    request_id: str


class User(BaseModel):
    id: int
    name: str


# パラメータ化した時点で、data の検証が効くようになる
resp = ApiResponse[User].model_validate(
    {"success": True, "data": {"id": 1, "name": "alice"}, "request_id": "req_x"}
)
print(resp.data.name)  # "alice" ← data は User として検証済み
```

> ⚠️ **It works only once parameterized**: validation works only in the **form given a type argument** like `ApiResponse[User]`. With bare `ApiResponse`, `DataT` is effectively treated as `Any`, and `data` isn't validated. You can DRY-express "same shape, changing contents" structures like a response envelope, pagination, and a `Result[T, E]`-style type (on Python 3.12+, the PEP 695 syntax `class ApiResponse[DataT](BaseModel)` is also usable).

---

## **7. Which to use when — a decision quick reference**

The techniques up to here don't compete. The tip for organizing is **to choose by "the granularity of what you want to validate."**

| What you want to do | The mechanism to use | Reason |
| --- | --- | --- |
| **Reuse** a single value/type's validation | `Annotated` + `AfterValidator`, etc. | Baking it into a type is DRY. Sharable across multiple models |
| A simple constraint (length, range, regex) | `Field(...)` / `annotated_types` / `StringConstraints` | declarative. Don't use `constr` |
| A 1-field validation **of a specific model** | `@field_validator` | model-specific logic is straightforward with a decorator |
| A validation that **spans multiple fields** | `@model_validator` | consolidate the invariant in the model (password match, etc.) |
| Build validation into a **custom type** inheriting `str`/`int` | `__get_pydantic_core_schema__` | library-like reuse. Can be a marker class too |
| **Polymorphic data** whose shape changes by kind | discriminated union (`discriminator`) | make invalid states unrepresentable + fast |
| Data whose root is `list`/`dict` | `RootModel` / `TypeAdapter` | validation where the top level isn't a model |
| A **general-purpose wrapper** whose contents change | `Generic[T]` | reuse a response envelope, etc., type-safely |

Finally, using `@model_validator(mode="wrap")`, you can **wrap the whole model's validation** and interpose logging or fallback on failure. It's effective when you want to reconcile cross-field validation and "observing broken input."

```python
import logging
from typing import Any
from typing import Self
from pydantic import BaseModel, ModelWrapValidatorHandler, ValidationError, model_validator


class Order(BaseModel):
    item_id: int
    quantity: int

    @model_validator(mode="wrap")
    @classmethod
    def log_on_failure(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
        try:
            return handler(data)
        except ValidationError:
            logging.warning("Order の検証に失敗: %r", data)  # PII に注意
            raise
```

---

## **Conclusion: promote validation to a "type" and make the invalid unrepresentable**

The goal of advanced validation isn't to be clever. It comes down to **promoting validation logic into reusable types and making invalid states impossible to create in the first place.** Let me re-list the key points of this article.

1. With the **`Annotated` pattern** (`AfterValidator`, etc.), bake validation into a type and make a **reusable domain type** like `Username`. The execution order is before/wrap right→left, after left→right.
2. Write **constraints with `Annotated` + `StringConstraints` / `annotated_types`.** Don't use `constr` / `conlist` (scheduled for deprecation in 3.0).
3. With **`__get_pydantic_core_schema__`,** build validation into a custom type, and control the strength of validation with `InstanceOf` / `SkipValidation`.
4. Express polymorphic data with a **discriminated union,** a root collection with **`RootModel`,** and a general-purpose wrapper with **`Generic[T]`,** each type-safely.

These are the very design power that changes "working code" into "code you can operate for 10 years." If validation is consolidated in types, a spec change completes in one point, and incorrect usage is rejected before compile or at validation.

As official primary sources, I recommend re-reading the following from this article's viewpoint.

- [Validators](https://pydantic.dev/docs/validation/latest/concepts/validators/)
- [Types](https://pydantic.dev/docs/validation/latest/concepts/types/)
- [Fields](https://pydantic.dev/docs/validation/latest/concepts/fields/)
- [Unions](https://pydantic.dev/docs/validation/latest/concepts/unions/)
- [Models (RootModel / Generic)](https://pydantic.dev/docs/validation/latest/concepts/models/)

---

### **Consultation on type-safe domain modeling**

The author designed and implemented the backend of a METI-Minister's-Award-winning B2B SaaS in Python and expressed the complex domain of distribution flow, pricing, and inventory by type. Modeling that "makes invalid states unrepresentable" structurally cuts off the source of bugs and lowers the maintenance cost over the long term. I advance, fast and at high quality leveraging generative AI, the foundation-building directly tied to the business's reliability — **the design of domain types using Pydantic v2, making existing models type-safe, and consolidating validation logic.** Please feel free to consult me.
