CLOVER🍀

That was when it all began.

Pythonのデータバリデーションライブラリー、Pydanticを試す

これは、なにをしたくて書いたもの?

PythonのデータバリデーションライブラリーであるPydanticを、ちょっと見てみようかなということで。

FastAPIなどで目にしていたのですが、Pydantic自体についてちゃんと見ていませんでしたし。

Pydantic

PydanticのWebサイトはこちら。

Welcome to Pydantic - Pydantic

GitHubリポジトリーはこちら。

GitHub - pydantic/pydantic: Data validation using Python type hints

Pydanticは、もっとも広く使われているythonのデータバリデーションライブラリーとされています。

Pydantic is the most widely used data validation library for Python.

Pydanticの特徴は、以下に書かれています。

  • 型ヒントを利用
  • コアのバリデーションロジックはRustで実装されており高速
  • JSONスキーマのサポート
  • StrictモードとLaxモード
    • Strictモード: データが変換されない厳密なモード
    • Laxモード(デフォルト): 適切な場合にデータを正しい型に強制変換するモード
  • DataclassesやTypedDictsのサポート
  • カスタマイズ可能
  • FastAPI、Hugging Face、Django Ninja、SQLModel、LangChainなど人気のライブラリーを含む、多数のパッケージがPydanticを使用しており、エコシステムができている
  • 月間3億6,000万回以上ダウンロードされており、実績がある

Pydantic / Why use Pydantic?

もっと詳細に見る場合は、こちらのようですね。

Why use Pydantic - Pydantic

Rustで実装されているのは知らなかったです。ちょっと驚きました。

ドキュメントをどう読み進めていけばよいかは、こちらのページを見るとよさそうです。

Help with Pydantic - Pydantic

主なガイドはこちら。

Models - Pydantic

APIリファレンス。

BaseModel - Pydantic

例。

Validating File Data - Pydantic

エラーハンドリング、エラーメッセージのカスタマイズ。

Error Handling - Pydantic

インテグレーション。

Pydantic Logfire - Pydantic

Mypyのプラグインなどもあったりするんですね。

ひとまず、簡単に使ってみようと思います。

環境

今回の環境はこちら。

$ python3 --version
Python 3.12.3


$ uv --version
uv 0.7.19

プロジェクトを作成してPydanticをインストールする

まずはuvでプロジェクトを作成します。

$ uv init --vcs none pydantic-getting-started
$ cd pydantic-getting-started
$ rm main.py

こちらを参考に、Pydanticをインストール。

$ uv add pydantic

Installation - Pydantic

今回は使いませんが、メールアドレスのバリデーションとtzdataから提供されるIANAタイムゾーンデータベースを扱う
オプショナルパッケージもあるようです。

# with the `email` extra:
$ uv add 'pydantic[email]'
# or with `email` and `timezone` extras:
$ uv add 'pydantic[email,timezone]'

Installation / Optional dependencies

テストなどに必要なライブラリーをインストール。

$ uv add --dev pytest mypy ruff

確認はテストコードで行うことにします。

pyproject.toml

[project]
name = "pydantic-getting-started"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "pydantic>=2.11.7",
]

[dependency-groups]
dev = [
    "mypy>=1.16.1",
    "pytest>=8.4.1",
    "ruff>=0.12.2",
]

[tool.mypy]
strict = true
disallow_any_unimported = true
disallow_any_expr = true
disallow_any_explicit = true
warn_unreachable = true
pretty = true
plugins = ["pydantic.mypy"]

Mypy向けのプラグインを追加しています。

plugins = ["pydantic.mypy"]

Mypy - Pydantic

インストールされたライブラリーの一覧。

$ uv pip list
Package           Version
----------------- -------
annotated-types   0.7.0
iniconfig         2.1.0
mypy              1.16.1
mypy-extensions   1.1.0
packaging         25.0
pathspec          0.12.1
pluggy            1.6.0
pydantic          2.11.7
pydantic-core     2.33.2
pygments          2.19.2
pytest            8.4.1
ruff              0.12.2
typing-extensions 4.14.1
typing-inspection 0.4.1

Pydanticを使ってみる

では、Pydanticを使ってみましょう。

Mypyプラグインを試す

…の前に、Mypyのプラグインの効果を見てみましょうか。このプラグインを使うと、モデルのフィールドに型アノテーション
指定されていないことや、引数に対するエラーをうまく扱えるようになるそうです。

ドキュメントの例をそのまま使ってみます。

Mypy - Pydantic

mypy_plugin.py

from datetime import datetime
from typing import Optional

from pydantic import BaseModel


class Model(BaseModel):
    age: int
    first_name = 'John'
    last_name: Optional[str] = None
    signup_ts: Optional[datetime] = None
    list_of_ints: list[int]


m = Model(age=42, list_of_ints=[1, '2', b'3'])
print(m.middle_name)  # not a model field!
Model()  # will raise a validation error for age and list_of_ints

Mypyを動かしてみます。

$ uv run mypy .
mypy_plugin.py:8: error: Explicit "Any" is not allowed  [explicit-any]
    class Model(BaseModel):
    ^~~~~~~~~~~~~~~~~~~~~~
mypy_plugin.py:10: error: Untyped fields disallowed  [pydantic-field]
        first_name = 'John'
        ^~~~~~~~~~~~~~~~~~~
mypy_plugin.py:16: error: Expression type contains "Any" (has type "list[Any]")  [misc]
    m = Model(age=42, list_of_ints=[1, '2', b'3'])
                                   ^~~~~~~~~~~~~~
mypy_plugin.py:17: error: "Model" has no attribute "middle_name"  [attr-defined]
    print(m.middle_name)  # not a model field!
          ^~~~~~~~~~~~~
mypy_plugin.py:17: error: Expression has type "Any"  [misc]
    print(m.middle_name)  # not a model field!
          ^~~~~~~~~~~~~
mypy_plugin.py:18: error: Missing named argument "age" for "Model"  [call-arg]
    Model()  # will raise a validation error for age and list_of_ints
    ^~~~~~~
mypy_plugin.py:18: error: Missing named argument "list_of_ints" for "Model"  [call-arg]
    Model()  # will raise a validation error for age and list_of_ints
    ^~~~~~~
Found 7 errors in 1 file (checked 1 source file)

ポイントはこちらですね。

mypy_plugin.py:10: error: Untyped fields disallowed  [pydantic-field]
        first_name = 'John'

よく見ると、first_nameだけ型アノテーションの指定がありません。

class Model(BaseModel):
    age: int
    first_name = 'John'
    last_name: Optional[str] = None
    signup_ts: Optional[datetime] = None
    list_of_ints: list[int]

ではここで、pyproject.tomlでMypyプラグインコメントアウトしてみます。

[tool.mypy]
strict = true
disallow_any_unimported = true
disallow_any_expr = true
disallow_any_explicit = true
warn_unreachable = true
pretty = true
#plugins = ["pydantic.mypy"]

確認。

$ uv run mypy .
mypy_plugin.py:8: error: Explicit "Any" is not allowed  [explicit-any]
    class Model(BaseModel):
    ^~~~~~~~~~~~~~~~~~~~~~
mypy_plugin.py:16: error: List item 1 has incompatible type "str"; expected "int"  [list-item]
    m = Model(age=42, list_of_ints=[1, '2', b'3'])
                                       ^~~
mypy_plugin.py:16: error: List item 2 has incompatible type "bytes"; expected "int"  [list-item]
    m = Model(age=42, list_of_ints=[1, '2', b'3'])
                                            ^~~~
mypy_plugin.py:17: error: "Model" has no attribute "middle_name"  [attr-defined]
    print(m.middle_name)  # not a model field!
          ^~~~~~~~~~~~~
mypy_plugin.py:17: error: Expression has type "Any"  [misc]
    print(m.middle_name)  # not a model field!
          ^~~~~~~~~~~~~
mypy_plugin.py:18: error: Missing named argument "age" for "Model"  [call-arg]
    Model()  # will raise a validation error for age and list_of_ints
    ^~~~~~~
mypy_plugin.py:18: error: Missing named argument "list_of_ints" for "Model"  [call-arg]
    Model()  # will raise a validation error for age and list_of_ints
    ^~~~~~~
Found 7 errors in 1 file (checked 1 source file)

変わったのはここですね。

mypy_plugin.py:16: error: List item 1 has incompatible type "str"; expected "int"  [list-item]
    m = Model(age=42, list_of_ints=[1, '2', b'3'])
                                       ^~~
mypy_plugin.py:16: error: List item 2 has incompatible type "bytes"; expected "int"  [list-item]
    m = Model(age=42, list_of_ints=[1, '2', b'3'])
                                            ^~~~

もともと検出されていた内容と見比べると、だいぶ見当違いな内容になってしまっていますね…。

mypy_plugin.py:10: error: Untyped fields disallowed  [pydantic-field]
        first_name = 'John'

よりPydanticのMypyプラグインにより厳密な動作をさせる場合は、以下のように設定するようです。

[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true

というわけで、今回はこんなpyproject.tomlでいくことにします。

pyproject.toml

[project]
name = "pydantic-getting-started"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "pydantic>=2.11.7",
]

[dependency-groups]
dev = [
    "mypy>=1.16.1",
    "pytest>=8.4.1",
    "ruff>=0.12.2",
]

[tool.mypy]
strict = true
disallow_any_unimported = true
disallow_any_expr = true
disallow_any_explicit = true
warn_unreachable = true
pretty = true
plugins = ["pydantic.mypy"]

[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
Pydanticを試す

それでは、Pydanticを試していきます。ここを試していきましょうか。

Pydantic / Pydantic examples

Pydanticの基本は、BaseModelクラスを継承したクラスを作成することのようです。

user.py

from datetime import datetime
from pydantic import BaseModel, PositiveInt


class User(BaseModel):
    id: int
    name: str = "John Doe"
    signup_ts: datetime | None
    tastes: dict[str, PositiveInt]

nameはデフォルト値のあるフィールド、signup_tsは任意のフィールドといった感じですね。

こちらのクラスを、テストコードで動作を確認していきます。今回は型定義に対するバリデーションが中心ですね。

test_user.py

from datetime import datetime

from pydantic import ValidationError
from user import User


## ここに、テストを書く!

コンストラクターに辞書を与えてインスタンスを構築。

def test_valid_user() -> None:
    external_data = {
        "id": 123,
        "signup_ts": "2019-06-01 12:22",
        "tastes": {
            "wine": 9,
            b"cheese": 7,
            "cabbage": 1,
        },
    }

    user = User(**external_data)

    assert user.id == 123
    assert user.name == "John Doe"
    assert user.signup_ts == datetime(2019, 6, 1, 12, 22, 0)
    assert user.tastes == {
        "wine": 9,
        "cheese": 7,
        "cabbage": 1,
    }

誤ったデータ型のものを与えたり、必須フィールドがなかったりするとバリデーションエラーになります。

def test_invalid_user() -> None:
    external_data = {"id": "not an int", "tastes": {}}

    try:
        User(**external_data)
    except ValidationError as e:
        assert e.errors() == [
            {
                "type": "int_parsing",
                "loc": ("id",),
                "msg": "Input should be a valid integer, unable to parse string as an integer",
                "input": "not an int",
                "url": "https://errors.pydantic.dev/2.11/v/int_parsing",
            },
            {
                "type": "missing",
                "loc": ("signup_ts",),
                "msg": "Field required",
                "input": {"id": "not an int", "tastes": {}},
                "url": "https://errors.pydantic.dev/2.11/v/missing",
            },
        ]

ところで、トップページを見るとコンストラクターインスタンスを構築していますが、BaseModel#model_validateを使うのが
よさそうな感じがしますね。

def test_invalid_user_use_model_validate() -> None:
    external_data = {"id": "not an int", "tastes": {}}

    try:
        User.model_validate(external_data)
    except ValidationError as e:
        assert e.errors() == [
            {
                "type": "int_parsing",
                "loc": ("id",),
                "msg": "Input should be a valid integer, unable to parse string as an integer",
                "input": "not an int",
                "url": "https://errors.pydantic.dev/2.11/v/int_parsing",
            },
            {
                "type": "missing",
                "loc": ("signup_ts",),
                "msg": "Field required",
                "input": {"id": "not an int", "tastes": {}},
                "url": "https://errors.pydantic.dev/2.11/v/missing",
            },
        ]

BaseModel - Pydantic

バリデーションエラーになる場合も同じ。

def test_valid_user_use_model_validate_json() -> None:
    external_data = """
    {
        "id": 123,
        "signup_ts": "2019-06-01 12:22",
        "tastes": {
            "wine": 9,
            "cheese": 7,
            "cabbage": 1
        }
    }
    """

    user = User.model_validate_json(external_data)

    assert user.id == 123
    assert user.name == "John Doe"
    assert user.signup_ts == datetime(2019, 6, 1, 12, 22, 0)
    assert user.tastes == {
        "wine": 9,
        "cheese": 7,
        "cabbage": 1,
    }

JSON文字列からインスタンスを構築する場合は、BaseModel#model_validate_jsonを使うようです。

def test_valid_user_use_model_validate_json() -> None:
    external_data = """
    {
        "id": 123,
        "signup_ts": "2019-06-01 12:22",
        "tastes": {
            "wine": 9,
            "cheese": 7,
            "cabbage": 1
        }
    }
    """

    user = User.model_validate_json(external_data)

    assert user.id == 123
    assert user.name == "John Doe"
    assert user.signup_ts == datetime(2019, 6, 1, 12, 22, 0)
    assert user.tastes == {
        "wine": 9,
        "cheese": 7,
        "cabbage": 1,
    }


def test_invalid_user_use_model_validate_json() -> None:
    external_data = """{"id": "not an int", "tastes": {}}"""

    try:
        User.model_validate_json(external_data)
    except ValidationError as e:
        assert e.errors() == [
            {
                "type": "int_parsing",
                "loc": ("id",),
                "msg": "Input should be a valid integer, unable to parse string as an integer",
                "input": "not an int",
                "url": "https://errors.pydantic.dev/2.11/v/int_parsing",
            },
            {
                "type": "missing",
                "loc": ("signup_ts",),
                "msg": "Field required",
                "input": {"id": "not an int", "tastes": {}},
                "url": "https://errors.pydantic.dev/2.11/v/missing",
            },
        ]

BaseModel#model_dumpでオブジェクトを辞書に、BaseModel#model_dump_jsonでオブジェクトをJSON文字列に
変換することもできます。

def test_dump_user() -> None:
    external_data = {
        "id": 123,
        "signup_ts": "2019-06-01 12:22",
        "tastes": {
            "wine": 9,
            b"cheese": 7,
            "cabbage": 1,
        },
    }

    user = User.model_validate(external_data)

    assert user.model_dump() == {
        "id": 123,
        "name": "John Doe",
        "signup_ts": datetime(2019, 6, 1, 12, 22, 0),
        "tastes": {"wine": 9, "cheese": 7, "cabbage": 1},
    }


def test_dump_json_user() -> None:
    external_data = {
        "id": 123,
        "signup_ts": "2019-06-01 12:22",
        "tastes": {
            "wine": 9,
            b"cheese": 7,
            "cabbage": 1,
        },
    }

    user = User.model_validate(external_data)

    assert (
        user.model_dump_json()
        == """{"id":123,"name":"John Doe","signup_ts":"2019-06-01T12:22:00","tastes":{"wine":9,"cheese":7,"cabbage":1}}"""
    )

ひとまず、こんなところでしょうか。

おわりに

Pythonのデータバリデーションライブラリーである、Pydanticを試してみました。

FastAPIなどで雰囲気で使っていたのですが、少しながらもじっくりと情報を見ておいてよかったかなと思います。

バリデーションの定義方法やバリデーションエラー時のメッセージのカスタマイズ方法なども把握しておいた方が
いいんでしょうね。