これは、なにをしたくて書いたもの?
PythonのデータバリデーションライブラリーであるPydanticを、ちょっと見てみようかなということで。
FastAPIなどで目にしていたのですが、Pydantic自体についてちゃんと見ていませんでしたし。
Pydantic
PydanticのWebサイトはこちら。
Welcome to Pydantic - Pydantic
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万回以上ダウンロードされており、実績がある
もっと詳細に見る場合は、こちらのようですね。
Rustで実装されているのは知らなかったです。ちょっと驚きました。
ドキュメントをどう読み進めていけばよいかは、こちらのページを見るとよさそうです。
主なガイドはこちら。
APIリファレンス。
例。
Validating File Data - 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
今回は使いませんが、メールアドレスのバリデーションと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"]
インストールされたライブラリーの一覧。
$ 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_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の基本は、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", }, ]
バリデーションエラーになる場合も同じ。
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などで雰囲気で使っていたのですが、少しながらもじっくりと情報を見ておいてよかったかなと思います。
バリデーションの定義方法やバリデーションエラー時のメッセージのカスタマイズ方法なども把握しておいた方が
いいんでしょうね。