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_validatorfor username normalization (trim whitespace, lowercase, length check) in each of theUser,Profile, andCommentmodels. - 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.
Fieldconstraint) 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."
| 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 |
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)]
⚠️
PlainValidatorcuts off validation: sincePlainValidatorruns "instead of" standard validation, the subsequent type validation isn't done. Even if you writeAnnotated[int, PlainValidator(f)], it's not guaranteed that the valuefreturned is anint— you need to guarantee the type atf's responsibility. From both performance and safety, considerAfterValidatorfirst.
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" likeconstr(min_length=3)andconlist(...)still work. But the official clearly states aboutconstrthat "this function recommends and prefers the use ofAnnotated+StringConstraints, and is deprecated. It will become deprecated in Pydantic 3.0." In new code, always write in theAnnotated[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
SkipValidationlast: per the official, sinceSkipValidationchanges the schema toany_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: extractRootModel's contents from the attribute.root. If you want to subscript liketags[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 bareApiResponse,DataTis effectively treated asAny, anddataisn't validated. You can DRY-express "same shape, changing contents" structures like a response envelope, pagination, and aResult[T, E]-style type (on Python 3.12+, the PEP 695 syntaxclass 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."
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.
- With the
Annotatedpattern (AfterValidator, etc.), bake validation into a type and make a reusable domain type likeUsername. The execution order is before/wrap right→left, after left→right. - Write constraints with
Annotated+StringConstraints/annotated_types. Don't useconstr/conlist(scheduled for deprecation in 3.0). - With
__get_pydantic_core_schema__, build validation into a custom type, and control the strength of validation withInstanceOf/SkipValidation. - Express polymorphic data with a discriminated union, a root collection with
RootModel, and a general-purpose wrapper withGeneric[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.