Skip to main content
友田 陽大
Pydantic & type-safe validation
Python
Pydantic
型安全
バリデーション
データモデリング
アーキテクチャ設計

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
Reading time
12 min read
Author
友田 陽大
Share

Introduction: validation logic "scatters"

In the Pydantic v2 practical guide, 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, 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.

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.

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."

ValidatorWhen it runsThe value it receivesMain use
AfterValidatorafter standard validationa type-guaranteed valuevalidation, normalization (first choice)
BeforeValidatorbefore standard validationraw input (Any)pre-shaping the input format
PlainValidatorinstead of standard validationraw input (Any)fully implement the validation logic yourself
WrapValidatorwraps standard validationAny + handlerexception catching, fallback
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.

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.

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.

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__.

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.

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

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.

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).

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__).

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.

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 doThe mechanism to useReason
Reuse a single value/type's validationAnnotated + AfterValidator, etc.Baking it into a type is DRY. Sharable across multiple models
A simple constraint (length, range, regex)Field(...) / annotated_types / StringConstraintsdeclarative. Don't use constr
A 1-field validation of a specific model@field_validatormodel-specific logic is straightforward with a decorator
A validation that spans multiple fields@model_validatorconsolidate 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 kinddiscriminated union (discriminator)make invalid states unrepresentable + fast
Data whose root is list/dictRootModel / TypeAdaptervalidation where the top level isn't a model
A general-purpose wrapper whose contents changeGeneric[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."

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.


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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading