【実践】JsonSchemaとPydantic - 自由なJSONデータで動的バリデーションを実現する

こんにちは、エンジニアのタカです。

今回は、私が直近で開発業務で使用しているJsonSchema(ジェイソン・スキーマ)の紹介と、PythonのPydantic(パイダンティック)モデルと組み合わせたバリデーションについて解説します。

JSONのメリットとのデメリット

JSON (JavaScript Object Notation)は「キーと値のペア」というシンプルで直感的なデータ定義形式が特徴です。

比較的自由に値を定義でき、高い可読性を持つ上に、プログラミング言語に依存しないフォーマットであるため、現在ではデータ交換フォーマットの事実上の標準として、多くのシステムで利用されています。

一方で、このJSONの柔軟性は、時に以下のような課題を引き起こすことがあります。

  • 構造の不透明性: JSON自体には、どのようなデータが存在し得るかという「型」や「必須項目」を定義する仕組みがありません。別途ドキュメントを準備する必要があり、また、アプリケーション側で意図しないデータが紛れ込んだり、あるいは期待するデータが存在しないといった状況が発生しやすくなります。
  • データ品質の低下: スキーマが存在しないことにより、データの中身に対する開発者間の認識にズレが生じやすくなります。これが原因でデータの品質が低下したり、予期せぬバグを引き起こしたりするリスクがあります。
  • バリデーションの複雑化: 受信したJSONデータのバリデーションをアプリケーション側で手動で実装しようとすると、コードが複雑化し、結果として保守性の低下を招く恐れがあります。

私が現在開発に携わっているシステムでも、PostgreSQLのテーブルにjsonb型のカラムを設け、JSON形式で可変長のデータを保存しています。

このデータはユーザーごとにJSONに保存するキーが異なる場合があり、一貫したデータ形式にならず、データのバリデーションに苦労していました。この問題を解消するために、JsonSchema という定義を導入することにしました。

JsonSchemaとは

JsonSchemaは、JSONデータの構造やルールを定義するスキーマ言語です。簡単に言うと、「JSONデータがどんな形をしているべきか」というルールを定めるもので、JSONの柔軟性ゆえに生じる前述の「構造の不透明性」や「データ品質の低下」といった課題を解消するために定義されました。

このJsonSchemaには、主に以下の3つの用途があります。

  1. データのバリデーション: 入力データが期待する形式に合致しているかの検証
  2. コード生成: 定義されたスキーマから任意の言語向けのデータモデルクラスやバリデータコードの自動生成
  3. ドキュメンテーション: データ構造の定義による開発者間の共通認識の形成

これらの用途について、実際のJsonSchemaのサンプルを交えて解説します。こちらのサンプルは、架空のユーザーデータを表したものです。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/user.json",
  "title": "User",
  "description": "User profile data",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid",
      "description": "Unique identifier for the user"
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100,
      "description": "User Name"
    },
    "email": {
      "type": "string",
      "format": "email",
      "description": "User email"
    },
    "is_active": {
      "type": "boolean",
      "default": true,
      "description": "User active status. active is true"
    }
  },
  "required": ["id", "name", "email"]
}

JsonSchemaは、規定のキーワードを用いて表現されます。以下に、代表的なものを記載します。

キーワード役割
$schemaスキーマが準拠するJsonSchemaのバージョンURI
$idスキーマを一意に識別するためのURI
title, descriptionスキーマの名称と説明
typeデータの基本型(object, string, integer, booleanなど)
propertiestypeobjectの場合に、オブジェクトが持つプロパティとその定義
requiredオブジェクトのプロパティのうち、必須となるもののリスト

1. データのバリデーション

JsonSchemaの最も基本的な用途がデータのバリデーションです。

propertiesキーワードの中に各プロパティごとのルールを定めることで、受け取ったJSONデータが期待通りの形式や値の範囲に適合しているかを自動的に検証できます。

前述のユーザーデータのサンプルでは、iduuid形式の文字列、nameは1文字以上100文字以下の文字列、emailはメールアドレス形式の文字列、is_activeは真偽値でデフォルトはtrue、といったルールになります。

また、idnameemailrequiredキーワードによって必須項目と定義されます。

このような厳格なデータチェックをコードで手動実装すると複雑になりがちですが、JsonSchemaを使えば宣言的にルールを記述できるため、可読性と保守性を高められます。

なお、AIモデルが生成する出力形式を指定するstructured output (構造化出力)というアプローチでもJsonSchemaが利用されており、AIモデルの出力をJsonSchemaで定義した形式に指定することで、AIの出力を他のシステムやアプリケーションが自動的に処理・解析しやすくなります。

2. コード生成

JsonSchemaは、データ構造の厳密な定義となるため、そこからアプリケーションコードを自動的に生成する基盤にもなります。

フロントエンド、バックエンドを問わず、JsonSchemaから各種ツールを用いて以下のようなものを生成できます。これらは、開発プロセスの効率化や、手動実装でのミスを削減する効果があります。

生成物説明
データモデルクラスPythonのPydanticモデル、
TypeScriptのインターフェースなどJsonSchemaで定義されたJSONの構造を、各プログラミング言語のオブジェクトとして扱うためのクラスやインターフェース
バリデータコード関数、クラスJsonSchemaに記述されたminLengthformatpatternといった具体的な制約に基づき、入力データが正しい形式であるかを検証するためのコード(関数やクラス)
APIドキュメントOpenAPI/Swaggerなど人間が読みやすい形式のAPIリファレンスドキュメント(Swagger UIのような対話型ドキュメント)。JsonSchemaは、OpenAPI Specificationの基盤としても使用されている

なお、本記事で後述するdatamodel-code-generatorはPydanticモデルを生成するツールです。スキーマが変更された際も、ツールを再実行すれば関連クラスを最新の状態に保つことができます。

3. ドキュメンテーション

JsonSchemaそのものが、JSONデータの持つプロパティの種類、データの型、制約、そして説明を詳細に記述しているため、これ自体が質の高いドキュメントとして機能します。

さらに、JsonSchemaのエコシステムには、JsonSchemaをより視覚的で分かりやすいHTMLドキュメントなどに変換するツールも存在します。個別のツールについては、公式サイトのToolページに記載があるので参照ください。

ただし、ドキュメンテーションツールに限らず、JsonSchemaのツールに関しては、対応するJsonSchemaバージョンによって利用できるものとそうでないものがあります。利用の際には$schemaキーワードで指定するバージョンに対応するものを選ぶ必要があります。

(補足) JsonSchemaのバージョンについて

JsonSchemaは継続的に仕様追加や変更が行われており、$schema キーワードで指定するURIでどのバージョンの仕様に準拠しているか示す必要があります。

これにより、JsonSchemaを処理するライブラリやツールは、どのルールセットに基づいてスキーマを解釈し、バリデーションやコード生成を行うべきかを判断できます。

主要なJsonSchemaのバージョンとその説明を以下に記載します。

バージョン名URI説明
Draft 4http://json-schema.org/draft-04/schema#広く採用された初期のバージョンであり、現在のJsonSchemaの基礎部分が定義された。
Draft 7http://json-schema.org/draft-07/schema#現在、最も広く使われているバージョン。if/then/else キーワードの追加により、複雑な条件に基づいたバリデーションが可能になった。
Draft 2020-12https://json-schema.org/draft/2020-12/schema最新安定版のバージョン。$dynamicRef$dynamicAnchorといったキーワードの追加で、より柔軟な再帰的参照が可能になるなど大規模なスキーマ定義がしやすくなった

実践: JsonSchemaを用いたバリデーション

ここまで、JsonSchemaの基本的な概念と用途について説明しました。ここからは、Python のライブラリである Pydantic を用いて、実際のアプリケーションにおける JsonSchemaを活用したバリデーション方法を紹介します。

Pydanticとは

Pydanticは、Pydanticモデルと呼ばれる、Pythonの型ヒントとその型定義に基づいたデータバリデーション機能を持つクラスを生成できるライブラリです。

Pydanticモデルは型ヒントを最大限に活用するため、IDEの補完機能や、mypyなどの静的型チェッカーと連携できます。これにより、単なるデータのバリデーションに留まらず、コード記述時からミスを減らすことで、開発者の工数削減が期待できます。

JsonSchemaからPydanticモデルを自動生成する

Pydanticモデルは手動で記述することもできますが、JsonSchemaからの自動生成が可能です。

今回、自動生成を datamodel-code-generator というツールを使用して行っていきます。本ツールは、JsonSchemaやSwaggerといったスキーマ定義から、Pydanticモデル(またはその他のデータクラス)を自動生成するためのコマンドラインツールです。

※なお、本ツールは、Pydanticの公式ドキュメントでも紹介されています

(補足): Pydantic vs. JsonSchema Library

PythonでJsonSchemaを使ったバリデーションを行うライブラリはPydanticだけではありません。例えば、jsonschema(JSON Schema Library)というPythonライブラリも存在します。

それぞれの特徴の比較は、以下の通りです。

特徴PydanticJSON Schema Library
JSON Schema標準への準拠⚠️ JSON Schemaのサブセットをサポートし、Pythonのデータモデルに最適化✅ JSON Schemaの標準仕様に厳密に準拠
パフォーマンス✅ 高速(v2ではRustベース)⚠️ 中程度(Pythonベース)
型安全性✅ Pythonの型ヒントに基づいてコードレベルでデータモデルの型チェックが可能⚠️ Pythonの辞書を直接扱うため、コード記述時のデータ構造の事前チェックは限定的
ライブラリの軽量性⚠️ 多くの機能を持つため、比較的大規模。✅ 検証に特化しているため、より軽量
学習コスト、依存関係⚠️ 中程度(データモデルの知識が必要)✅ 低い(JSON Schemaの知識のみ)
既存コード統合⚠️ 導入に際して既存コードの改修が必要な場合がある✅ 辞書ベースのデータをそのまま組み込み可能
カスタムバリデーション✅ 豊富なバリデーター、カスタムロジック追加可⚠️ 基本的な検証のみ
エラーメッセージ✅ 詳細で分かりやすい⚠️ 基本的だが十分
実行時安全性✅ 型エラーを事前検出⚠️ 実行時エラーのリスク
IDEサポート✅ 補完・型チェック完全対応❌ 辞書アクセスのみ
データ変換✅ 自動型変換・シリアライズ❌ 検証のみ(変換機能なし)

それぞれメリット・メリットがありますが、今回は、JsonSchemaから生成されるデータモデルをPythonのクラスとして扱いたい点、そして型安全性や開発効率のメリットを享受したい点から、Pydanticを用いてバリデーションを行っていきます。

datamodel-code-generator の導入と基本的な使い方

それでは、datamodel-code-generatorを導入し、実際にPydanticモデルを生成してみます

導入

pipを使ってインストールします。

pip install datamodel-code-generator

基本的な使い方

JsonSchemaの解説で用いたuser_schema.jsonを例に、Pydanticモデルを生成してみます。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/user.json",
  "title": "User",
  "description": "User profile data",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid",
      "description": "Unique identifier for the user"
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100,
      "description": "User Name"
    },
    "email": {
      "type": "string",
      "format": "email",
      "description": "User email"
    },
    "is_active": {
      "type": "boolean",
      "default": true,
      "description": "User active status. active is true"
    }
  },
  "required": ["id", "name", "email"]
}

user_schema.jsonファイルと同じディレクトリで、以下のコマンドを実行します。

datamodel-code-generator --input user_schema.json --input-file-type jsonschema --output user_models.py
  • --input user_schema.json: 入力となるJsonSchemaファイルを指定します。
  • --input-file-type jsonschema: 入力ファイルの種別がJsonSchemaであることを明示します。
  • --output user_models.py: 生成されるPythonファイルの出力先を指定します。

コマンドを実行すると、user_models.pyというPythonファイルが生成されます。

# generated by datamodel-codegen:
#   filename:  user_schema.json
#   timestamp: 2025-07-01T08:54:39+00:00

from __future__ import annotations

from typing import Optional
from uuid import UUID

from pydantic import BaseModel, EmailStr, Field, constr

class User(BaseModel):
    id: UUID = Field(..., description='Unique identifier for the user')
    name: constr(min_length=1, max_length=100) = Field(..., description='User Name')
    email: EmailStr = Field(..., description='User email')
    is_active: Optional[bool] = Field(
        True, description='User active status. active is true'
    )

JsonSchemaで定義したデータが、PythonのPydanticモデルのクラスに変換されています。descriptionmin_lengthmax_lengthといったJsonSchemaのプロパティごとの制約が、PydanticのField関数に適切にマッピングされているのが分かります。

アプリケーションへの組み込みとバリデーション実行

生成されたPydanticモデルを使い、実際のJSONデータのバリデーションを行ってみましょう。不正なデータが入力された場合に、Pydanticがどのようにエラーを検知し、更に詳細なエラーメッセージをJSON形式で出力することを確認します。

from pydantic import ValidationError
from user_models import User
import json

# JSONデータの例
json_data = {
    "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
    "name": "Alice",
    "email": "alice@example.com",
    "is_active": True,
}

try:
    # JSONデータをPydanticモデルにパース(バリデーションも自動実行)
    user = User.model_validate(json_data)
    print("データは正常にパースされました。")
    print(user.model_dump_json(indent=2))

    # 型ヒントの恩恵を受ける
    print(f"\\nユーザーの名前: {user.name}")

    # 不正なデータでのバリデーションエラー
    invalid_json_data = {
        "id": "invalid-uuid",  # 無効なUUID
        "name": "",  # 最小文字数違反
        "is_active": "invalid-boolean"  # パターン違反
        # emailは必須項目
    }
    User.model_validate(invalid_json_data)

except ValidationError as e:
    print("\\n▼ カスタムエラーメッセージ")
    error_messages = {}
    for error in e.errors():
        # locタプルをドット区切りの文字列に変換
        # (例: ('address', 'city') -> "address.city")
        field = ".".join(map(str, error['loc']))
        message = error['msg']
        error_messages[field] = message

    # 辞書をJSONとして出力
    print(json.dumps(error_messages, indent=2, ensure_ascii=False))

コマンドラインでの実行と出力結果です。

python input.py

データは正常にパースされました。
{
  "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "name": "Alice",
  "email": "alice@example.com",
  "is_active": true
}

ユーザーの名前: Alice

▼ カスタムエラーメッセージ
{
  "id": "Input should be a valid UUID, invalid character: expected an optional prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `i` at 1",
  "name": "String should have at least 1 character",
  "email": "Field required",
  "is_active": "Input should be a valid boolean, unable to interpret input"
}

この通り、JsonSchemaの定義に基づき、Pydanticが詳細かつ分かりやすいエラーメッセージを生成してくれました。これにより、どのフィールドでどのような問題が発生したのかを特定し、デバッグやエラーハンドリングを効率的に行うことができます。

実践: 動的カラムのバリデーション

次に、本記事の冒頭で触れた、PostgreSQLのjsonb型カラムに保存されたユーザーごとに異なる形式のデータのバリデーションを行っていきます。

このJSONデータはキー名などに一貫性のないデータ形式であり、この課題を解決するため、JsonSchemaとPydanticを組み合わせた動的バリデーションのアプローチを導入します。

具体的には、ユーザーごとに異なるJsonSchemaをデータベースに保存し、アプリケーション実行時にそのJsonSchemaを読み込んでPydanticモデルを動的に生成し、バリデーションを行います。

ここでは、動的なデータの一例として、ユーザーのprofileを表すJsonSchema(例:profile_schema.json)を定義します。このスキーマは、実際にはユーザーごとにDBに格納されるJsonSchemaを模したものとして扱います。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/profile.json",
  "title": "UserProfile",
  "description": "User profile data. This can vary from user to user.",
  "type": "object",
  "properties": {
    "hobbies": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "List of user hobbies"
    },
    "hometown": {
      "type": "string",
      "description": "User's hometown"
    },
    "favorite_programming_languages": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": ["Python", "JavaScript", "Java", "Go", "Other"]
      },
      "description": "Favorite programming languages"
    }
  },
  "required": ["hobbies"]
}

このprofile_schema.jsonは、hobbiesが必須の文字列配列で、favorite_programming_languagesは特定のenum値のみを許容する文字列配列、といったルールを定義しています。

JsonSchemaを読み込み、Pydanticモデルを動的に生成する

今回のケースように、ユーザーごとにスキーマが異なり、実行時に動的にスキーマを切り替えてバリデーションを行う場合は、datamodel-code-generatorなどでのPydanticモデルの事前生成はできません。

このようなケースでは、Pydanticライブラリのcreate_model関数を用いることで、実行時にJsonSchemaを読み込み、その内容に基づいてPydanticモデルを動的に生成し、model_validate関数でバリデーションを行うことができます。

以下のコードでは、JsonSchemaの型定義をPythonの型にマッピングするユーティリティ関数を実装し、これを用いてJsonSchemaからPydanticモデルを生成します。

import json
from pathlib import Path
from typing import Any, Dict, List, Type
from pydantic import BaseModel, create_model, ValidationError, EmailStr
from uuid import UUID

# JSON SchemaのタイプをPythonの型にマッピング
SCHEMA_TYPE_MAP = {
    "string": str,
    "number": float,
    "integer": int,
    "boolean": bool,
    "array": List,
    "object": Dict,
}

# JSON Schemaのフォーマット指定を特定の型にマッピング
FORMAT_TYPE_MAP = {
    "uuid": UUID,
    "email": EmailStr,
}

def schema_to_pydantic(
    schema: Dict[str, Any], model_name: str, exclude_fields: List[str] = None
) -> Type[BaseModel]:
    # フィールド定義を格納する辞書
    fields = {}
    # スキーマからプロパティ一覧を取得
    properties = schema.get("properties", {})
    # 除外フィールドリストを初期化(Noneの場合は空リスト)
    exclude_fields = exclude_fields or []

    # 各プロパティを順次処理してPydanticフィールドに変換
    for key, prop in properties.items():
        # 除外対象フィールドはスキップ
        if key in exclude_fields:
            continue

        if "type" in prop:
            # フォーマット指定がある場合は専用の型を使用(UUID、Emailなど)
            if prop.get("format") in FORMAT_TYPE_MAP:
                field_type = FORMAT_TYPE_MAP[prop["format"]]
            else:
                # 通常のタイプマッピングを適用
                field_type = SCHEMA_TYPE_MAP.get(prop["type"], Any)

        # フィールドが必須かどうかを判定
        is_required = key in schema.get("required", [])
        default_value = ... if is_required else prop.get("default")

        # フィールド定義を追加(型, デフォルト値)
        fields[key] = (field_type, default_value)

    # 動的にPydanticモデルクラスを生成して返す
    return create_model(model_name, **fields)

def validate_schema_data(
    data: Dict[str, Any],
    model: Type[BaseModel]
) -> Dict[str, Any]:
    """
    独立したスキーマデータを検証する関数
    """
    validated_data = model.model_validate(data)
    return validated_data.model_dump(mode='json')

def main():
    # profile_schema.jsonファイルを読み込み
    try:
        profile_schema = json.loads(Path("profile_schema.json").read_text())
    except FileNotFoundError as e:
        print(f"エラー: {e}")
        return

    # profile_schemaのPydanticモデルを動的生成
    ProfileModel = schema_to_pydantic(
        profile_schema, profile_schema.get("title", "UserProfile"))

    print("✅ 動的Pydanticモデル生成完了")
    print(f"モデル名: {ProfileModel.__name__}")
    print(f"モデルフィールド: {list(ProfileModel.model_fields.keys())}")

    # テストケース1: 正常なプロファイルデータの検証
    valid_profile_data = {
        "hobbies": ["プログラミング", "読書", "映画鑑賞"],
        "hometown": "東京都",
        "favorite_programming_languages": ["Python", "JavaScript", "Go"]
    }

    try:
        validated_profile_data = validate_schema_data(
            valid_profile_data, ProfileModel)
        print("\\n✅ テストケース1: 正常プロファイルデータテスト成功")
        print(json.dumps(validated_profile_data, indent=2, ensure_ascii=False))
    except ValidationError as e:
        print("❌ 予期しないエラー")
        print(e)

    # テストケース2: 不正なプロファイルデータの検証(必須フィールド不足・不正値)
    invalid_profile_data = {
        "hometown": "大阪府",
        "favorite_programming_languages": ["InvalidLanguage", "Python"]
        # hobbiesが不足している(必須フィールド)
    }

    try:
        validate_schema_data(invalid_profile_data, ProfileModel)
        print("❌ エラーが発生すべきでした")
    except ValidationError as e:
        print("\\n✅ テストケース2: 不正プロファイルデータテスト成功")
        print("▼ エラーメッセージ")
        # エラーメッセージを整理して表示
        error_messages = {}
        for error in e.errors():
            field = ".".join(map(str, error['loc']))
            error_messages[field] = error['msg']
        print(json.dumps(error_messages, indent=2, ensure_ascii=False))

if __name__ == "__main__":
    main()

出力結果は以下になります。

python input.py

✅ 動的Pydanticモデル生成完了
モデル名: UserProfile
モデルフィールド: ['hobbies', 'hometown', 'favorite_programming_languages']

✅ テストケース1: 正常プロファイルデータテスト成功
{
  "hobbies": [
    "プログラミング",
    "読書",
    "映画鑑賞"
  ],
  "hometown": "東京都",
  "favorite_programming_languages": [
    "Python",
    "JavaScript",
    "Go"
  ]
}

✅ テストケース2: 不正プロファイルデータテスト成功
▼ エラーメッセージ
{
  "hobbies": "Field required"
}

おわりに

本記事では、JsonSchemaについて、用途やバージョン、Pydanticモデルへの変換と活用方法について解説しました。

課題だったPostgreSQLのjsonb型のような動的なカラムに保存される可変長のデータに対しても、JsonSchemaとPydanticの機能を組み合わせることで、実行時にスキーマを読み込んで動的にバリデーションを行い、柔軟なデータ構造を扱いながらもデータ品質とアプリケーションの信頼性を確保することが出来ました。

JsonSchemaは、JSONデータの信頼性を保証し、開発プロセスの様々な面の効率化もできる便利な定義だと実感したため、同じような悩みを持つ方は、ぜひ一度本記事で紹介した内容を試してみてください。

なお、次回は複数のアプリケーションで利用する共通のスキーマ定義やその管理方法など、さらにJsonSchemaを用いた実践的な内容ついて記事を書いていこうと思います。

どうぞよろしくお願いします。

SHARE

  • facebook
  • twitter

SQRIPTER

AGEST Engineers

AGEST

記事一覧

AGESTのエンジニアが情報発信してます!
QAエンジニア、テストエンジニア、クオリティマネージャー、SI、フロントエンド・バックエンド、インフラ、セキュリティエンジニア、プロジェクトマネージャーなどAGEST所属の各エンジニアが専門分野や得意分野の記事を執筆しています。

株式会社AGEST

RANKINGアクセスランキング
#TAGS人気のタグ
  • 新規登録/ログイン
  • 株式会社AGEST
NEWS最新のニュース

Sqriptsはシステム開発における品質(Quality)を中心に、エンジニアが”理解しやすい”Scriptに変換して情報発信するメディアです

  • 新規登録/ログイン
  • 株式会社AGEST