Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第6章 dataclass と Pydantic によるデータモデリング

現代の Python アプリケーション開発では、単なるプリミティブ型や辞書(dict)だけではなく、ドメインモデル、設定値、API リクエスト・レスポンスなどの構造化データを安全かつ明確に表現する「データモデリング」が極めて重要です。

Python においてデータ構造を定義する主要な手法として、標準ライブラリの dataclasses と、サードパーティ製でデファクトスタンダードの検証ライブラリ Pydantic の 2 つが存在します。本章では、それぞれの仕組み、詳細な機能、および実践的な使い分けと設計パターンを体系的に解説します。

6.1 データモデリングにおける課題とツールの選択基準

Python の辞書型(dict)は柔軟で扱いやすい反面、大規模なコードベースやチーム開発では以下のような深刻な課題を引き起こします:

  • キー名のタイポ検知が困難: 実行時に KeyError が発生するまで間違いに気付けない。
  • 型情報とエディタ補完の欠如: どのようなフィールドが存在し、各フィールドがどのような型を持つのかコードから把握しづらい。
  • 不正な値の混入: 文字列が入るべき場所に数値が渡されても、実行時にそのまま通過してしまう。

これらの課題を解決するために、クラスベースのデータモデリング手法が用いられます。

dataclass と Pydantic の比較

比較項目標準ライブラリ dataclassesPydantic (v2)
提供元Python 標準ライブラリ(Python 3.7+)サードパーティ(pydantic パッケージ)
実行時オーバーヘッドほぼゼロ(純粋な Python クラスと同等)わずかに発生(Rust コア pydantic-core により高速)
実行時バリデーションなし(型ヒントは注釈のみで実行時は無視)あり(厳格な型検証・値の制約チェックを実施)
型強制 (Type Coercion)なし(渡された値をそのまま保持)あり(例: 文字列 "100" を整数 100 に自動変換)
JSON / 辞書シリアライズasdict() による簡易変換のみmodel_dump(), model_dump_json() 等の高機能
主な用途内部ドメインモデル、計算処理、軽量な値オブジェクト外部境界(API 入出力、設定ファイル、DB 境界)

6.2 標準ライブラリ dataclass の徹底活用

dataclasses モジュールは、クラス定義に @dataclass デコレータを付与するだけで、__init__()、__repr__()、__eq__() などの定型メソッド(ボイラープレート)を自動生成してくれる標準機能です。

基本的な定義

from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    is_active: bool = True

user = User(id=1, name="Alice")
print(user)  # User(id=1, name='Alice', is_active=True)

不変オブジェクトの作成 (frozen=True)

予期しない属性の変更を防ぎ、安全な「値オブジェクト(Value Object)」を作りたい場合は、frozen=True を指定します。

@dataclass(frozen=True)
class Money:
    amount: int
    currency: str = "JPY"

price = Money(amount=1000)
# price.amount = 2000  # FrozenInstanceError が発生する!

frozen=True を指定したクラスは __hash__() メソッドも自動生成されるため、辞書のキーや set の要素として安全に利用できます。

field() 関数による詳細制御

リストや辞書などのミュータブル(変更可能)なオブジェクトをフィールドのデフォルト値にする場合、直接代入するとすべてのインスタンスで同一オブジェクトが共有されてしまいます。これを防ぐために field(default_factory=...) を使用します。

from dataclasses import dataclass, field

@dataclass
class Department:
    name: str
    # ミュータブルなデフォルト値には必ず default_factory を使用する
    members: list[str] = field(default_factory=list)
    # repr(文字列表現)や比較から除外したい内部プロパティ
    _internal_cache: dict[str, str] = field(default_factory=dict, repr=False, compare=False)

dept = Department(name="開発部")
dept.members.append("Bob")
print(dept)  # Department(name='開発部', members=['Bob'])

post_init による初期化後処理と検証

インスタンス生成直後に派生フィールドを計算したり、値の整合性を簡易チェックしたい場合は、__post_init__() メソッドを実装します。

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)  # __init__ の引数から除外

    def __post_init__(self) -> None:
        if self.width <= 0 or self.height <= 0:
            raise ValueError("幅と高さは正の数値である必要があります")
        self.area = self.width * self.height

rect = Rectangle(width=4.0, height=5.0)
print(rect.area)  # 20.0

パフォーマンス向上とキーワード専用引数 (slots / kw_only)

Python 3.10 以降では、slots=True や kw_only=True オプションが追加されました。

  • slots=True: __slots__ を自動定義し、インスタンスのメモリ使用量を削減し、属性アクセスを高速化します。
  • kw_only=True: インスタンス化の際にすべての引数をキーワード引数(name="Alice")として渡すことを強制し、引数の渡し間違いを防ぎます。
@dataclass(slots=True, kw_only=True)
class Point3D:
    x: float
    y: float
    z: float

# キーワード指定が必須
point = Point3D(x=1.0, y=2.0, z=3.0)

辞書やタプルへの相互変換

標準ライブラリの asdict() および astuple() を使用することで、容易に標準データ構造へ変換できます。

from dataclasses import asdict, astuple

user_dict = asdict(user)    # {'id': 1, 'name': 'Alice', 'is_active': True}
user_tuple = astuple(user)  # (1, 'Alice', True)

6.3 Pydantic による堅牢なバリデーションとスキーマ定義

外部から入力されるデータ(HTTP リクエストの JSON、環境変数、設定ファイルなど)は、開発者の想定外の型や不正な値が含まれる可能性があります。

Pydantic は、Python の型ヒントを利用して実行時にデータを厳密に解析・検証し、保証された型を持つオブジェクトを生成する強力なライブラリです。

プロジェクトへの Pydantic の追加

uv を用いてプロジェクトに pydantic を追加します:

uv add pydantic

BaseModel の基本と型強制

Pydantic のデータモデルは、pydantic.BaseModel を継承して定義します。

from pydantic import BaseModel, ValidationError

class UserCreateInput(BaseModel):
    id: int
    name: str
    email: str
    age: int | None = None

# 文字列の "100" が自動的に int の 100 に型強制 (Type Coercion) される
user = UserCreateInput(id="100", name="Bob", email="bob@example.com")
print(user.id)        # 100 (int 型)
print(type(user.id))  # <class 'int'>

型が不一致で変換できない場合や必須項目が欠落している場合、ValidationError が発生し、詳細なエラー情報が提供されます:

try:
    UserCreateInput(id="not-a-number", name="Bob", email="invalid")
except ValidationError as e:
    print(e.json())

Field() による制約とメタデータの付与

Field() を用いることで、数値の範囲、文字列の長さ、正規表現パターンなどの詳細な制約を宣言的に付与できます。

from pydantic import BaseModel, Field

class Product(BaseModel):
    sku: str = Field(min_length=3, max_length=10, description="商品識別コード")
    price: int = Field(gt=0, description="価格は0より大きい整数")
    discount_rate: float = Field(default=0.0, ge=0.0, le=1.0, description="割引率(0〜1)")
    tags: list[str] = Field(default_factory=list, max_length=5)

カスタムバリデータ (@field_validator と @model_validator)

Field() だけでは表現できない複雑な検証や正規化ルールは、デコレータを用いて実装します。

フィールド単位の検証 (@field_validator)

from pydantic import BaseModel, field_validator

class UserRegister(BaseModel):
    username: str
    email: str

    @field_validator("username")
    @classmethod
    def validate_username(cls, value: str) -> str:
        if not value.isalnum():
            raise ValueError("ユーザー名は半角英数字のみ使用可能です")
        return value.lower()

    @field_validator("email")
    @classmethod
    def validate_email(cls, value: str) -> str:
        if "@" not in value:
            raise ValueError("有効なメールアドレス形式ではありません")
        return value.strip().lower()

複数フィールド間の整合性検証 (@model_validator)

モデル全体の属性が確定した後に複数のフィールドを比較検証する場合、@model_validator(mode="after") を使用します。

from datetime import date
from pydantic import BaseModel, model_validator

class BookingRequest(BaseModel):
    start_date: date
    end_date: date

    @model_validator(mode="after")
    def check_dates(self) -> "BookingRequest":
        if self.start_date >= self.end_date:
            raise ValueError("終了日は開始日より後の日付である必要があります")
        return self

ネストしたモデル(階層構造)のモデリング

Pydantic はモデル同士のネスト(入れ子)を自然にサポートしており、複雑な JSON 構造も容易にモデリングできます。

class Address(BaseModel):
    city: str
    postal_code: str

class Customer(BaseModel):
    id: int
    name: str
    address: Address
    tags: list[str] = []

# ネストした辞書から自動的に Address インスタンスが生成される
customer = Customer(
    id=1,
    name="Charlie",
    address={"city": "Tokyo", "postal_code": "100-0001"}
)
print(customer.address.city)  # Tokyo

6.4 データのシリアライズとデシリアライズ

Web API や設定ファイルとの連携では、JSON や辞書との相互変換が日常的に行われます。Pydantic v2 では、直感的かつ統一されたシリアライズ API が提供されています。

入力データのパース (デシリアライズ)

  • 辞書からの生成: Model.model_validate(dict_data)
  • JSON 文字列からの直接生成: Model.model_validate_json(json_str)(Rust コアで直接パースするため極めて高速)
json_text = '{"city": "Osaka", "postal_code": "530-0001"}'
addr = Address.model_validate_json(json_text)
print(addr.city)  # Osaka

出力データの変換 (シリアライズ)

  • 辞書への変換: model.model_dump()
  • JSON 文字列への変換: model.model_dump_json()

不要なデータを除外したり、特定の出力形式に制御するオプションが充実しています:

# 未設定のデフォルト値を除外して辞書化
clean_dict = customer.model_dump(exclude_unset=True)

# None のフィールドを除外して JSON 化
json_output = customer.model_dump_json(exclude_none=True, indent=2)

フィールドエイリアスとキャメルケース変換

外部 API(JSON)がキャメルケース(userId)、Python 側がスネークケース(user_id)である場合、ConfigDict と alias_generator を用いて自動変換できます。

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class CamelModel(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,  # スネークケースでもキャメルケースでも入力可能にする
    )

    user_id: int
    first_name: str
    is_registered: bool

# キャメルケースの JSON から生成可能
item = CamelModel.model_validate({"userId": 10, "firstName": "Taro", "isRegistered": True})
print(item.user_id)  # 10

# JSON 出力時はキャメルケースに戻す
print(item.model_dump_json(by_alias=True))
# {"userId":10,"firstName":"Taro","isRegistered":true}

6.5 dataclass と Pydantic の実践的な使い分けと連携

システム全体の設計において、dataclass と Pydantic を適材適所で使い分けることが、パフォーマンスと安全性を両立させる鍵となります。

アーキテクチャ視点での境界分離

モダンなアーキテクチャ(クリーンアーキテクチャやオニオンアーキテクチャ)では、以下のように役割を明確に分担します:

  1. システム外部の境界層 (Interface / Adapter):
    • Web リクエストの受信、CLI 引数、環境変数、外部 API との通信。
    • データの信頼性が低いため、Pydantic を用いて厳格に型変換とバリデーションを実施します。
  2. システム内部のコア層 (Domain / Application):
    • 業務ロジック、不変値オブジェクト、アルゴリズム計算。
    • 境界層で一度検証を通過したデータは、依存関係がなくオーバーヘッドのない dataclass(frozen=True) で表現します。
graph LR
    subgraph External["外部境界 (Untrusted)"]
        API["Web API リクエスト"]
        ENV["環境変数・設定"]
    end

    subgraph BoundaryLayer["境界検証層"]
        PYDANTIC["Pydantic BaseModel<br/>(型強制・厳格バリデーション)"]
    end

    subgraph InternalDomain["内部ドメイン層 (Trusted)"]
        DATA["dataclass(frozen=True)<br/>(軽量・不変・高速ロジック)"]
    end

    API --> PYDANTIC
    ENV --> PYDANTIC
    PYDANTIC -->|検証済みデータを渡す| DATA

pydantic.dataclasses による統合アプローチ

標準の dataclass と同じインターフェースを保ちつつ、Pydantic の強力なバリデーション機能を利用したい場合、pydantic.dataclasses.dataclass デコレータを利用することもできます。

from pydantic.dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float

# 標準 dataclass と同様に扱え、初期化時に Pydantic の型検証が走る
coord = Coordinate(latitude="35.6895", longitude="139.6917")
print(coord.latitude)  # 35.6895 (自動で float に変換される)

選択の判断基準フロー

データ構造を新規作成する際は、以下の判断フローを参考に選択してください:

flowchart TD
    Start["データ構造の設計を開始"] --> Q1{"外部からの信頼できないデータ<br/>(API, JSON, 設定ファイル等) か?"}
    Q1 -- Yes --> UsePydantic["Pydantic BaseModel を使用<br/>(実行時バリデーション & 型強制)"]
    Q1 -- No --> Q2{"外部依存関係(サードパーティ)を<br/>排除したい、または純粋な計算処理か?"}
    Q2 -- Yes --> UseDataclass["標準 dataclass(frozen=True) を使用<br/>(軽量・高速・依存ゼロ)"]
    Q2 -- No --> Q3{"標準 dataclass 記法を維持しつつ<br/>バリデーションを行いたいか?"}
    Q3 -- Yes --> UsePydanticDataclass["pydantic.dataclasses.dataclass を使用"]
    Q3 -- No --> UseDataclass

dataclass の持つ「軽快さと標準ライブラリの安心感」、そして Pydantic の持つ「堅牢な検証力とシリアライズ機能」の特性を理解して組み合わせることで、堅牢で保守性の高い Python アプリケーションを構築できます。