Pydantic v2: Pythonの入力データの検証

1. 概要

HTTPリクエスト、JSONファイル、またはメッセージキューからのデータは、しばしば型が誤っている、フィールドが不足している、または不合理な値を持っています。if/elseで散在した検査を行うと、コードはすぐに混乱してしまいます。Pydantic v2を使うと、型ヒントを使ってデータの「形」を説明でき、その後自動的に解析、型変換、詳細なエラー報告ができます。

この記事は、Pydantic v2の主なツールを取り扱います:BaseModel、Field、field_validator、model_validator、ConfigDict、TypeAdapter。最後はpytestを使ったテストの書き方です。この記事を読み終えると、自分のアプリケーションの入力を安全に検証するモデルを自分で構築できるようになります。

2. インストール

この記事ではPython 3.11+とPydantic 2.xを使用しています:

pip install "pydantic>=2.7" pytest

EmailStr型が必要な場合は、追加の補足部分をインストールしてください:

pip install "pydantic[email]"

3. BaseModelを使った基本的なモデル

3.1. 宣言と型強制

モデルはBaseModelを継承するクラスです。型ヒントを持つ各属性はフィールドです:

from pydantic import BaseModel

class Customer(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True

customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>

id=“42”を文字列として渡しますが、Pydanticが自動的にintに変換します。これはlax modeで、デフォルトのモード:変換が合理的であればPydanticは型強制を行います。フィールドis_activeはデフォルト値を持つため、渡す必要はありません。

3.2. ValidationErrorの処理

データが正しくない場合、Pydanticは ValidationErrorをスローし、最初のエラーで停止せずすべてのエラーを一度に集約します:

from pydantic import ValidationError

try:
    Customer(id="abc", name="An")
except ValidationError as e:
    print(e.error_count())
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required

各エラーは辞書で、loc(フィールドの位置)、type(安定したエラーコード)、msg(ユーザー向けのメッセージ)で構成されます。クライアントにエラーを返すときは、locとtypeに基づくべきです。msgの内容はバージョン間で変更される可能性があります。

4. FieldとAnnotatedを使った値の制約

型ヒントは型チェックのみを行います。値を制限するにはFieldを使います:

from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field

Quantity = Annotated[int, Field(gt=0, le=100)]

class OrderItem(BaseModel):
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    quantity: Quantity
    unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')

説明:

  • patternはskuに正規表現にマッチすることを強制します。例えばABC-1234など。
  • QuantityはAnnotatedのおかげで再利用可能な型です。Quantityを宣言するすべてのフィールドは(0, 100]の範囲内である必要があります。
  • unit_priceはDecimalを使用して浮動小数点エラーを回避します。値は正である必要があり、最大2桁の小数点を持つ必要があります。

間違ったデータを渡してみてください:

try:
    OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than

その他の一般的な制約:min_length/max_lengthは文字列とリスト用、ge/ltは数値用、multiple_ofは倍数用。

5. field_validatorを使ったカスタム検証

正規表現または値の範囲より複雑な制約の場合、独自の検証関数を記述します:

from pydantic import BaseModel, Field, field_validator

class Contact(BaseModel):
    name: str
    phone: str = Field(pattern=r"^0\d{9}$")

    @field_validator("name")
    @classmethod
    def clean_name(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("name cannot be empty")
        return v.title()

    @field_validator("phone", mode="before")
    @classmethod
    def normalize_phone(cls, v):
        if isinstance(v, str):
            v = v.replace(" ", "").replace(".", "")
            if v.startswith("+84"):
                v = "0" + v[3:]
        return v

contact = Contact(name="  nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'

区別すべき2つのモードがあります:

  • mode="after"(デフォルト)、clean_nameなど:Pydanticが型チェックを行った後に実行されるため、vは確実にstrです。
  • mode="before"、normalize_phoneなど:型チェックとpatternのステップ前に実行されます。vは何でもあり得るため、isinstanceで確認する必要があります。ここでは+84 912 345 678を0912345678に正規化し、その後patternが適用されます。

検証関数は値を返す必要があります。戻り値がフィールドに格納される値です。エラーを報告する場合は、ValueErrorまたはAssertionErrorをスローします。Pydanticはこれをvalue_errorエラーでラップします。

注意: デコレータの順序は非常に重要です。 @field_validatorは@classmethodの上に配置する必要があります。逆にすると、クラス定義の時点で理解しがたいエラーが発生します。

6. model_validatorを使った複数フィールドの同時検証

複数のフィールドに関連するいくつかのルールがあります。たとえば、チェックアウト日はチェックイン日の後である必要があります。この場合はmodel_validatorが必要です:

from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator

class Booking(BaseModel):
    room_id: int
    check_in: date
    check_out: date

    @model_validator(mode="after")
    def check_dates(self) -> Self:
        if self.check_out <= self.check_in:
            raise ValueError("check_out must be after check_in")
        return self

try:
    Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
    err = e.errors()[0]
    print(err["loc"], err["msg"])
() Value error, check_out must be after check_in

mode=“after”では、検証関数は各フィールドが有効なインスタンスを受け取ります。self.check_inに通常の属性としてアクセスでき、return selfする必要があります。エラーにはloc()が空です。これはモデル全体に属し、特定のフィールドに結びついていないためです。文字列“2026-10-20”も自動的にdateに解析されます。

7. ConfigDictを使ったモデルの構成

model_configはモデルの全体的な動作を制御します。これは支払いなどの機密データに対して一般的に使用される構成です:

from pydantic import BaseModel, ConfigDict, ValidationError

class PaymentRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        strict=True,
        str_strip_whitespace=True,
        frozen=True,
    )

    order_id: int
    amount: int
    currency: str

try:
    PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden

各オプションの意味:

  • extra="forbid":couponなどの不明なフィールドを拒否します。デフォルトは“ignore”で、Pydanticは静かに余分なフィールドを無視します。これはクライアント側でのフィールド名の入力ミスエラーを隠しやすくします。
  • strict=True:型強制をオフにします。“1”はintに対して受け入れられなくなります。
  • str_strip_whitespace=True:文字列の前後の空白を自動的にトリムします。
  • frozen=True:インスタンスは不変です。payment.amount = 0を割り当てるとエラーがスローされます。

特定のフィールドのみをstrictにしたい場合は、Field(strict=True)またはStrictInt、StrictStrなどの型を使用できます。

知る価値がある別のオプションはvalidate_assignment=Trueです。デフォルトでは、オブジェクト作成後に属性を再割り当てするときPydanticは検証しません。モデルを常に有効に保つにはこのオプションをオンにしてください。

8. 辞書、JSON、および任意の型からの検証

実際には、データは通常dictまたはJSON文字列の形式で来ます。Pydantic v2は2つのクラスメソッドを提供します:

raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)

raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)

print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}

入力がJSON文字列の場合、json.loadsとその後のmodel_validateの代わりにmodel_validate_jsonを使用する必要があります。これはPydanticのRustコアが解析と検証を1つのステップで行うため、より高速です。逆方向では、model_dump()がdictを返し、model_dump_json()がJSON文字列を返します。

モデルではない型(例えばlist[OrderItem])を検証する必要がある場合は、TypeAdapterを使用します:

from pydantic import TypeAdapter

items_adapter = TypeAdapter(list[OrderItem])

items = items_adapter.validate_python([
    {"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
    {"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50

TypeAdapterをモジュールレベルで1回作成して再利用する必要があります。初期化はスキーマ構築のコストがあるため、リクエストごとに繰り返すべきではありません。

9. pytestを使ったテスト作成

検証はビジネスロジックなので、テストが必要です。上記のモデルがmodels.pyファイルにあると仮定します:

from decimal import Decimal

import pytest
from pydantic import ValidationError

from models import Booking, Contact, OrderItem


def test_order_item_coerces_valid_input():
    item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")

    assert item.quantity == 3
    assert item.unit_price == Decimal("19.90")


@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
    with pytest.raises(ValidationError) as exc_info:
        OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")

    error = exc_info.value.errors()[0]
    assert error["loc"] == ("quantity",)
    assert error["type"] in {"greater_than", "less_than_equal"}


def test_contact_normalizes_phone():
    contact = Contact(name="an", phone="+84 912.345.678")

    assert contact.phone == "0912345678"
    assert contact.name == "An"


def test_booking_rejects_checkout_before_checkin():
    with pytest.raises(ValidationError, match="check_out must be after check_in"):
        Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")

テストを実行します:

pytest -q
.....                                                    [100%]
5 passed

テストはlocとtypeをアサートし、msgの原文を比較しないことに注意してください。こうすることで、Pydanticが新しいバージョンでメッセージテキストを変更してもテストが壊れません。parametrizeは1つの関数で0と101の両方の境界をカバーするのに役立ちます。

10. よくあるエラーと使うべきでない場合

v1のAPIを誤って使用する。 多くの古いドキュメントはまだv1の構文を使用しています。クイックリファレンステーブル:

v1                         v2
@validator                 @field_validator
@root_validator            @model_validator
class Config: ...          model_config = ConfigDict(...)
.dict() / .json()          .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw()  .model_validate() / .model_validate_json()

古いAPIは依然として実行されますがDeprecationWarningを発行します。新しいAPIに完全に移行する必要があります。

Lax modeに驚く。 Lax modeは“yes”をTrueに、1.0を1に受け入れます。財務データまたはIDの場合、この動作は望ましくない可能性があります。その場合はstrictを有効にしてください。

検証関数でreturnを忘れる。 returnのないfield_validatorはフィールドがNone値を受け取ります。return selfのないmodel_validator(mode=“after”)もエラーを引き起こします。

知らずに検証をスキップする。 model_construct()はvalidationを通さないインスタンスを作成します。このメソッドは、自分のデータベースから読み直したデータなど、完全に信頼できるデータに対してのみ使用できます。

注意: Pydanticをすべての内部オブジェクトに使用すべきではありません。既にクリーンな数百万のオブジェクトを処理するホットループでは、検証のコストは無用です。その場合、dataclassまたはNamedTupleの方が適切です。Pydanticをシステムの境界に配置してください:API、設定ファイル、他のシステムからのメッセージ。

11. 結論

Pydantic v2は入力データの検証をコマンドを記述する代わりに宣言に変えます。型ヒントで型を説明し、FieldとAnnotatedで制約を追加し、field_validatorとmodel_validatorでカスタムルールを記述します。不要なフィールドを禁止したり、型強制をオフにしたりするなどの一般的な動作はConfigDictで制御されます。

3つのポイント:JSON入力にはmodel_validate_jsonを使い、重要なデータではextra=“forbid”を有効にし、メッセージコンテンツではなくloc/typeに基づいて検証をテストしてください。システムの境界で正しく適用されると、Pydanticはアプリケーションのコア部分が既にクリーンで正しい型のデータのみで作業することを確実にします。

pythonpydanticvalidationpytest