Python dataclass vs Pydantic|外部データの扱いで使い分ける

プログラミング

Lambdaで外部APIのレスポンスを受け取る処理を書いていたとき、「これdataclassで足りるのか、Pydantic使うべきか」で毎回迷う。毎回なんとなく判断してるけどちゃんと整理したことなかったので、今回まとめてみました。

dataclassとPydanticの根本的な違い

dataclassはPython 3.7から標準ライブラリに入ったデコレータで、__init____repr____eq__といったボイラープレートを自動生成してくれる仕組みです。追加ライブラリは不要。

一方のPydanticは、データバリデーションと設定管理に特化したライブラリで、型ヒントを使ってデータモデルを定義し、データの型チェックだけでなく値の範囲や形式などの詳細なバリデーションを行えます。v2からpydantic-coreというRust実装のコアが使われるようになって、速度も改善されました。

一番大事な違いをひとことで言うと:

  • dataclass:型ヒントは書けるが、実行時に型チェックはしない
  • Pydantic:実行時にバリデーション・型変換まで全部やってくれる

dataclassは「きれいな構造体」、Pydanticは「自動検査付きの構造体」というイメージです。Pydanticは主にデータの検証や変換を自動で行うため、入力データが不正である可能性が高い場合に非常に便利。一方dataclassはデータの保持や簡単な構造体として使用するのに適しています。

dataclassの基本

from dataclasses import dataclass, field

@dataclass
class Product:
    name: str
    price: float
    tags: list[str] = field(default_factory=list)

p = Product(name="Coffee", price=2.99)
print(p)  # Product(name='Coffee', price=2.99, tags=[])

シンプルで読みやすい。インポートも標準ライブラリだけなので依存関係を増やしたくないときには重宝します。

ただし、これをやってもエラーにはならない:

p = Product(name=123, price="高い")  # 型が違っても普通に動く
print(p)  # Product(name=123, price='高い', tags=[])

型指定を行ったとしても、バリデーションやデータ型の検証は自動では行われません。型ヒントはあくまでアノテーション。実行時は素通りです。

__post_init__で簡易バリデーション

一応、__post_init__を使えばインスタンス化のタイミングで検証は入れられます。

@dataclass
class Product:
    name: str
    price: float

    def __post_init__(self):
        if self.price < 0:
            raise ValueError(f"price must be >= 0, got {self.price}")

ただ、これを毎回手書きするのはしんどいし、エラーメッセージも自前で作る必要がある。フィールドが増えると地獄になりがち。

Pydanticの基本(v2)

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str
    price: float = Field(ge=0)  # 0以上の制約
    tags: list[str] = []

p = Product(name="Coffee", price=2.99)
print(p)  # name='Coffee' price=2.99 tags=[]

型が違ったり制約を外れるとValidationErrorを投げてくれます。しかもエラーメッセージが親切。

from pydantic import ValidationError

try:
    Product(name="Coffee", price=-1)
except ValidationError as e:
    print(e)
# 1 validation error for Product
# price
#   Input should be greater than or equal to 0 ...

さらに、型の自動変換もやってくれます。

p = Product(name="Coffee", price="2.99")  # 文字列→floatに変換
print(p.price, type(p.price))  # 2.99 

APIレスポンスやJSONって文字列で数値が来ることあるじゃないですか。そこをいちいち変換しなくていいのは地味に助かります。

JSON変換も標準装備

p = Product(name="Coffee", price=2.99, tags=["hot", "popular"])

# dict化
print(p.model_dump())
# {'name': 'Coffee', 'price': 2.99, 'tags': ['hot', 'popular']}

# JSON文字列化
print(p.model_dump_json())
# {"name":"Coffee","price":2.99,"tags":["hot","popular"]}

dataclassだとasdict()はあるけどJSON化は自前でやる必要があるので、この辺はPydanticのほうが断然ラクです。

実は3つある:Pydantic dataclassという選択肢

ここ、自分もしばらく混乱してたんですが、PydanticにはBaseModel以外にPydantic dataclassというものがあります。

from pydantic.dataclasses import dataclass  # pydantic版のdataclass

@dataclass
class User:
    id: int
    name: str

u = User(id="42", name="Alice")  # 文字列→intに変換
print(u)  # User(id=42, name='Alice')

Pydantic dataclassは見た目や記述が@dataclassに近く、既存のdataclass設計を大きく崩さずにバリデーションを付けたいときに向きます。通常のdataclassからの移行コストが低いのはメリット。

ただしPydantic dataclassはPydantic BaseModelの代替ではありません。バリデーション目的ならBaseModelのほうが素直。.model_dump_json()など、BaseModelが持つメソッドの一部が使えないケースもあります。

この3択(標準dataclass / Pydantic dataclass / BaseModel)に気づいてないまま「Pydantic使ってるのにメソッドがない」みたいなことが割と起きる気がします。

使い分けの判断基準

整理するとこんな感じになりました。

標準dataclass Pydantic BaseModel
外部ライブラリ 不要 必要
ランタイムバリデーション なし あり
型の自動変換 なし あり
JSON / dict変換 asdict()のみ model_dump() / model_dump_json()
バリデーションエラー詳細 自前実装 標準で詳細なメッセージ
frozen(不変) @dataclass(frozen=True) model_config = ConfigDict(frozen=True)
パフォーマンス 軽い v2から改善(Rustコア)

個人的な使い分けの判断はこう:

  • 内部ロジックのデータ構造(自分でコントロールしてるデータ)→ dataclassで十分
  • 外部からのデータ(APIレスポンス、JSONファイル、ユーザー入力)→ Pydantic
  • 設定ファイルの読み込み→ Pydantic v2だとpydantic-settingspydantic_settings.BaseSettings)が専用パッケージであります

「信頼境界を越えるかどうか」という視点で考えると迷いにくい気がします。自分たちで値を完全にコントロールできるなら素のdataclass、外部からのデータでバリデーションが必要ならPydantic。

実際のコード例:FastAPI + Pydantic

自分がよく使うのはLambdaへのAPIリクエストのパースです。FastAPIだとPydanticがそのまま使えます:

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class ItemRequest(BaseModel):
    name: str
    quantity: int = Field(gt=0)
    price: float

@app.post("/items")
def create_item(item: ItemRequest):
    # ここに来た時点でバリデーション済み
    return {"created": item.model_dump()}

リクエストボディが型違いだったりquantityが0以下だったりしたら、FastAPIが自動で422エラーを返してくれます。バリデーションのコードを1行も書かなくていい。

逆に、Lambda内部の処理で中間データを持ち回すだけなら:

from dataclasses import dataclass

@dataclass
class ProcessingResult:
    item_id: str
    status: str
    processed_at: str

これで十分。依存関係も増えないし、軽いです。

frozen と slots も地味に使える

@dataclass(frozen=True)を使うと、インスタンス化後に値を変更しようとするとFrozenInstanceErrorが発生します。設定オブジェクトや、ドメイン駆動設計の値オブジェクトなど「作ったら変えない」データに向いています。

@dataclass(frozen=True)
class Config:
    host: str
    port: int

cfg = Config(host="localhost", port=8080)
cfg.port = 9090  # FrozenInstanceError が発生

Python 3.10からはslots=Trueも使えて、フィールドを固定サイズの配列で保持することでメモリ使用量を削減できる場合があります。大量にインスタンスを作るような処理では効いてくる可能性があります。

Pydanticでもmodel_config = ConfigDict(frozen=True)で同様のことができます。

※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

まとめ

dataclassとPydanticはどちらが上とかじゃなくて、ちゃんと用途が分かれてます。「外部データを扱うか」「バリデーションが必要か」という2点で判断すれば大体迷わない気がします。

自分のデフォルトは、LambdaやFastAPIで外部とやり取りする部分はPydantic、内部でデータを受け渡すだけのところはdataclass。信頼できない境界ではPydantic、信頼できる内部ロジックではdataclassという感じで使い分けています。

参考になったらクリックしてもらえると嬉しいです!

Blogmura ProgrammingProgramming Ranking
タイトルとURLをコピーしました