JSON Toolkit

二つの JSON 文書が異なるとき:位置差分、id ベース差分、まず正規化

要点:50 件が 51 件になると、差分ツールは 51 件の変更を報告します。テキスト差分の誤検知、id による配列の照合、jq の正規化、適用可能なパッチを扱います。

初級約 6 分で読めます2026-10-30 公開2026-10-30 更新
二つの JSON 文書を比較するためのカバー画像

要点:まず正規化してから比較しましょう。jq -S か安定したシリアライズでキー順、インデント、数値の綴りを消し、順序が意味を持たない配列は業務キーで並べ替えるかインデックス化し、それから差分をパッチに変えます。

JSON の差分はテキストの差分ではありません。ファイルを文字列として比較すると、キーの並べ替えでページ全体が赤くなり、配列をインデックスで比較すると、1 レコードの挿入が他の 50 件を変更済みにします。

注文サービスにパッチを出荷し、手元にあるのは二つのレスポンスだけ。50 件が 51 件になり、新しいレコードが真ん中に一つ挿入されています。差分ツールは 51 件の変更を報告し、画面は赤一色。それでもどのレコードが実際に動いたのか分かりません。

ツールは壊れていません。「比較する」は JSON 上で三つの異なる意味を持ち、多くの人は既定で最も役に立たないものに手を伸ばします。

誤報を再現する:1 件の挿入、51 件の変更

Node 22 で 30 行。50 件の注文({id, sku, qty})を作り、末尾に 1 件追加し、先頭に 1 件挿入し、インデックスごとの整列で差分を数えます。

操作 レコード数 位置で報告される変更
末尾に 1 件追加 50 → 51 1
先頭に 1 件挿入 50 → 51 51

同じ「1 件多い」で、雑音は 51 倍。理由はありふれています。インデックスで比較すると、新しい要素は位置 0 に落ち、既存のすべてのレコードが一つずつ下へずれます。位置 0 対位置 0、位置 1 対位置 1。等しいペアは一つもありません。

代わりに id をキーとする Map を構築すると、同じデータはちょうど一つのエントリを報告します。$[id=ORD-999] added。

この実験が、どんなツール推奨よりも重要な規則を与えます。配列が順序付きの並びか、順序なしの集合かが、どの比較を使うべきかを決めます。

テキスト差分が JSON について嘘をつく三つの方法

diff a.json b.json は文字を比較します。JSON はデータ構造を運びます。この不一致が三種類の確実な誤検知を生みます。

1. オブジェクトのキー順の変更

RFC 8259 の第 4 節は明示しています。オブジェクトは「名前と値のペアの順序を持たない集合」です。次の二つの文書は同じ値です。

{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }

行差分は三行すべてを変更済みと呼びます。10 個のキーを持つオブジェクトのキー順を逆にし、両方を整形して行ごとに比較すると、10 行すべてが検出され、構造比較では等しいと判定されます。雑音比 10:0。

なぜキー順は変わるのか。シリアライザの切り替え、キーで並べ替えるフォーマッタの実行、辞書やハッシュマップからのオブジェクト再構築、言語ランタイム間の移動。どれか一つで、同一のデータがディスク上で異なって見えます。

2. 空白とインデント

{"a":1} と {\n "a": 1\n} は同じ値にパースされます。テキスト差分は改行と空白を見て、すべてを検出します。

3. 数値とエスケープの異なる綴り

1.0 と 1、1e2 と 100、"\u00e9" と "é"。パース後は同じ値、シリアライズ後は異なるテキストです。これは触れる行が少ないので比較的軽いですが、設定レビューでは数値が変わったと思わせるのに十分です。

逆のケースはテキストには見えず構造には見えます。5 と "5"。引用符が一対増えただけですが、型がずれています。通常は更新ではなくバグです。構造比較は迷わず modify と報告します。

三つの比較モード、三つの仕事

モード 比較するもの 雑音の源 出力 向く場面
テキスト差分 文字の行 キー順、インデント、数値の綴り 行番号、赤/緑 翻訳バンドル、キー順が安定したフィクスチャ
構造差分 パース済みツリーをパスで整列 インデックスで整列した配列 add/remove/modify 付きのパス API レスポンス、設定、Webhook
意味差分 ツリーと業務キー 悪いキー選択のみ move/insert 検出付きのパス レコードリスト、注文、在庫

構造差分は「キー順と整形」の層を解きます。意味差分はさらに一歩進んで「配列要素の同一性」を解きます。違いは実装の複雑さではなく、どのフィールドがレコードのパスポートかをツールに教える覚悟があるかどうかです。

配列が分水嶺

一つの問いを立てます。この配列の順序自体がデータか。

  • 順序がデータ:移行ステップ、検索ランキング、チャートの点、承認チェーン。位置の変化は振る舞いの変化なので、位置比較が正しい。
  • 順序が無意味:タグ、権限 ID の集合、注文明細、在庫リスト。挿入が他の項目の判定に影響すべきではありません。

順序が無意味なときは、位置で叩くのではなくキーでレコードを索引化します。

// id で照合する:挿入や削除は 1 エントリを報告し、他の項目はそのまま
function diffById(before, after, path = '$') {
  const out = [];
  const beforeMap = new Map(before.map((item) => [item.id, item]));
  const afterMap = new Map(after.map((item) => [item.id, item]));

  for (const [id, value] of afterMap) {
    if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
  }
  for (const [id, value] of beforeMap) {
    if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
    else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
      out.push({ path: `${path}[id=${id}]`, type: 'changed' });
    }
  }
  return out;
}

成熟したライブラリはこれをオプションにしています。jsondiffpatch は LCS で最長共通部分列を見つけますが、配列内のオブジェクト要素は既定で位置で照合されます。公式ドキュメントは、内容で照合するには objectHash 関数を渡さなければならないと強調しています。初めて使う人はライブラリが愚かだと結論づけますが、実際にはキーを渡していないだけです。

キーの選択も重要です。id が安全な選択です。sku は SKU が一意なときだけ機能します。オブジェクト全体をハッシュにすると「1 フィールドの変更」が「1 件削除と 1 件追加」になり、位置比較より悪化します。

最速のターミナル技:jq で正規化する

ライブラリを入れたくなく、ただ見たいだけなら、まず正規化してから差分を取ります。jq -S はオブジェクトのキーを再帰的に並べ替え、一貫したインデントを適用します。

# 正規化してから行ごとに比較する。キー順とインデントの雑音が消える
diff -u <(jq -S . before.json) <(jq -S . after.json)

# 配列が本当に順序なしなら、比較前にキーで並べ替える
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
        <(jq -S '.items |= sort_by(.id)' after.json)

printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . を実行すると {"a":1,"arr":[1,2],"b":2} が得られます。キーは並び替えられ、arr はそのままです。覚えておいてください。-S はオブジェクトのキーだけを並べ替え、配列の要素は決して並べ替えません。配列は明示的に sort / sort_by で並べ替えるか、位置の雑音を受け入れます。

二つの罠:

  • jq はインプレースで編集できず、標準出力に書きます。プロセス置換(<(...))を使うか、一時ファイルに書いて検証してから置き換えます。
  • -r は文字列から引用符を剥がすので、出力に改行やシェル上意味を持つ文字が含まれえます。信頼できないデータは、先へパイプする前に引用符で囲んでください。

このサイトの /diff がすること、しないこと

内蔵の JSON 比較ツールは同じ構造的アプローチに従います。両側をツリーにパースし、パスで再帰的に整列し、path + type(add/remove/modify) + before/after を生成します。オブジェクトのキー順は結果に影響せず、すべてブラウザ内で動くので、貼り付けたペイロードは決してアップロードされません。

しかしその配列はインデックスで整列されます。これは欠陥ではなく意図的なトレードオフです。

  • 末尾への追加、同じ位置の変更、丸ごとの置換は綺麗な出力になります。
  • 先頭や中間へのレコード挿入は、それ以降のすべてを modify とマークし、「実は 1 件だけ新しい」ことに自分で気づかなければなりません。

ですから注文リストを比較するときの正しい手は、両側を id で並べ替えてから貼ることです。並べ替えた入力は挿入点を固定し、雑音を 1 エントリに畳みます。インストール不要で、日常のほとんどのケースをカバーします。

「適用可能なパッチを生成する」「移動した要素を検出する」が必要なら、ページ内ツールはそれをしません。次の節です。

差分をパッチに変える:二つの形式、混ぜない

人間は差分を読み、プログラムはパッチを適用します。JSON には意味論がまったく異なる二つのパッチ形式があります。

観点 JSON Patch(RFC 6902) JSON Merge Patch(RFC 7396)
形 op、path、value を持つ操作の配列 対象文書に似た形の JSON オブジェクト
配列 対応。インデックス指定 /items/0、/- で追加 非対応。配列は丸ごと置換
キーの削除 remove null 経由のみ。値が正当に null のフィールドは削除できない
移動の表現 move / copy なし
向く場面 配列を含むリソース、監査ログ、原子的更新 素のオブジェクトの部分更新、設定配布

JSON Patch には人々が飛ばす操作が一つあります。test です。

[
  { "op": "test", "path": "/version", "value": 3 },
  { "op": "replace", "path": "/status", "value": "shipped" }
]

test が失敗するとパッチ全体が拒否されます。これはプロトコルに組み込まれた楽観的並行性で、「バージョン 3 を見たから書いた」を保証するために追加の If-Match ヘッダーは不要です。PATCH エンドポイントでは、どんなドキュメント慣習より価値があります。

パスは JSON Pointer(RFC 6901)を使います。~0 は ~、~1 は / をエスケープします。スラッシュを含むキー(MIME タイプをキーにする場合など)はエスケープしなければ、ポインタは別の場所に着地します。詳細はJSON Pointer と Patchにあります。

実践的な問題が一つ残ります。差分から自動生成したパッチは通常、配列をインデックスで指定します。インデックスは本番で脆く、並行挿入が一つあれば /items/3 はもはや同じレコードを指しません。生成パッチをオブジェクトのフィールドに限定するか、適用前に重要なフィールドを test で検証してください。

比較を CI に入れる:テキストではなく値を表明する

三つの規則で十分です。

  1. テストではパース後の値を表明する。JSON.stringify の出力を表明しない。Jest では toEqual、Python では json.loads 後に assert a == b。キー順とインデントは参加しません。
  2. 動的フィールドをホワイトリスト化する。requestId、timestamp、traceId は毎回異なり、除外しなければテストは永遠に赤です。jsondiffpatch の propertyFilter や deepdiff の exclude_paths はこのためにあります。
  3. 失敗時はパスを出力し、文書全体を出さない。5 MB のペイロードをログで読む人はいません。$.data.items[id=ORD-042].qty: 2 → 3 なら問題を即座に特定できます。

設定ドリフトも同じです。ステージングと本番の設定を両方 jq -S し、配列を業務キーで並べ替え、残ったものが本当の差異です。この種の問題、つまり同じデータが環境間で異なって見える問題は、通常は未定義の契約で、API 契約設計が扱う主題です。

FAQ

キー順は差異として数えますか?

いいえ。RFC 8259 はオブジェクトを順序なしの集合と定義するので、{"a":1,"b":2} と {"b":2,"a":1} は同じ値です。並べ替えを変更として報告するツールは、JSON 比較ではなくテキスト比較をしています。

1 レコードを挿入すると全エントリが変更済みになるのはなぜですか?

ツールが配列をインデックスごとに整列するからです。新しい要素は位置 0 に座り、既存のすべてのレコードが一つ下へずれ、どの二つの位置も一致しません。位置比較ではなく、id のようなキーで並べ替えるか索引化してから比較してください。

オンライン比較ツールはデータをアップロードしますか?

実装次第です。サーバー描画のツールはペイロードをバックエンドに送り、ログに残るかもしれません。クライアント側のツールはブラウザでパースし、何もアップロードしません。本番設定やユーザーのペイロードを比較する前に、ページがローカルで計算することを確認するか、ネットワークを切って再試行してください。

差分の結果をそのままパッチに使えますか?

そのままでは使えません。差分はどこが異なるかを言い、JSON Patch はどの操作を順に実行するかを言います。間に変換層があります。生成パッチは配列をインデックスで指定しがちで、並行下で壊れるので、適用前に test 操作を足してください。

数十 MB のファイルはどう比較しますか?

まずストリーミングします(jq -c は 1 レコード 1 行で書きます)。または関心のあるサブツリーだけを抽出します。たとえば jq '.data.items'。数十 MB の完全な再帰比較はまだメモリに収まりますが、100 MB を超えたら分割するかキーで索引化してください。具体的なメモリと時間の数値は大きな JSON のパフォーマンスを参照してください。

比較前に配列を id で並べ替えるべきですか?

はい。内蔵の JSON 比較ツールは配列をインデックスで整列するので、先頭や中間に 1 レコードを挿入すると、それ以降のすべてが modify とマークされます。貼る前に両側を id で並べ替えれば、挿入点が固定され、雑音が 1 エントリに畳まれます。例外は配列の順序自体がデータの場合(移行ステップ、検索ランキング、チャートの点)で、そこでは位置比較が正しく、並べ替えは誤りです。