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

要点:まず正規化してから比較しましょう。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 に入れる:テキストではなく値を表明する
三つの規則で十分です。
- テストではパース後の値を表明する。
JSON.stringifyの出力を表明しない。Jest ではtoEqual、Python ではjson.loads後にassert a == b。キー順とインデントは参加しません。 - 動的フィールドをホワイトリスト化する。
requestId、timestamp、traceIdは毎回異なり、除外しなければテストは永遠に赤です。jsondiffpatch のpropertyFilterや deepdiff のexclude_pathsはこのためにあります。 - 失敗時はパスを出力し、文書全体を出さない。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 エントリに畳まれます。例外は配列の順序自体がデータの場合(移行ステップ、検索ランキング、チャートの点)で、そこでは位置比較が正しく、並べ替えは誤りです。