同じ JSON なのに 4 つのツールが 4 通りの結果を出す:フォーマッタの選び方
要点:整形は無損ではありません。先頭ゼロ・BOM・20 桁整数を含む同じ JSON を Python、Node、jq、json.tool に通すと、エラーになるものもあれば黙って値を書き換えるものもあります。10 件の自走サンプルで 4 経路を比較し、選定表と貼り付け可能な厳密検証スクリプトを示します。

結論:フォーマッタの決定的な差はインデントの幅ではなく、同じ入力がエラーになるか、あるいは黙ってデータを書き換えるかです。機能一覧を読むより、境界値サンプルを 1 本走らせる方がはるかに確実です。
「整形は再排版にすぎない」と思われがちですが、実際には各経路が独自のパーサでテキストを解釈し直しています。パーサが寛容だと、出るはずのエラーが静かな値の書き換えに変わり、画面には「整形成功」の緑が灯ったままになります。
1. 現象:同じ入力、4 経路、4 つの結果
検証環境は Python 3.11.1、Node v22.13.1、jq 1.7。手作業で作った短い JSON 10 件を標準入力から送り、ディスクに落とさず設定も変えませんでした。経路は json.loads、JSON.parse、jq -c .、python -m json.tool の 4 つです。表中 ERR は例外で中断、SILENT はエラーなしだが出力が入力と一致しないことを示します。
| サンプル | python json.loads | node JSON.parse | jq -c . | python -m json.tool |
|---|---|---|---|---|
S1 {"a":1,"b":[1,2]} |
OK | OK | OK | OK |
S2 {"a":1,} |
ERR | ERR | ERR | ERR |
S3 {'a':1} |
ERR | ERR | ERR | ERR |
S4 {"a":01} |
ERR char 6 | ERR position 6 | SILENT {"a":1} |
ERR |
S5 {"a":1 /*x*/} |
ERR char 7 | ERR | ERR | ERR |
S6 \ufeff{"a":1} |
ERR utf-8-sig | ERR position 0 | SILENT {"a":1} |
ERR |
S7 {"id":9007199254740993} |
OK 忠実 | SILENT 9007199254740992 | OK 忠実 | OK |
S8 {"a":1,"a":2} |
SILENT {"a":2} |
SILENT {"a":2} |
SILENT {"a":2} |
OK |
S9 {"a":1 |
ERR char 6 | ERR | ERR EOF | ERR |
S10 <html>502</html> |
ERR char 0 | ERR Unexpected token '<' | ERR | ERR |
10 件中 4 件(S4・S6・S7・S8)が「エラーなしだが値が変わった」でした。緑の成功表示だけを信じると、4 割の確率で元のデータとは別のものを手にしています。しかもこれらは構文エラー一覧に載らず、ファイルは開ける・コピーできる・書き出せます。誤りは構文ではなく中身にあります。
2. 原因:構文層と意味層は別物
RFC 8259 の 1.2 節は ECMA-404 が許す実践を相互運用性の観点から避けるよう助言し、4 節はオブジェクト名を SHOULD で一意としつつ重複キーの扱いを規定せず、6 節は数字について相互運用上の助言のみを与えます。ECMA-404 はさらに「数字の意味論には関知しない」と明言し、表現だけを定めます。つまり仕様は広い境界領域を実装者に委ねています。
そのため「整形」は二層に分かれます。構文層がテキストの妥当性を判定し、意味層がそのテキストをどんな値にするかを決める。前者には標準回答があり、後者にはありません。jq が 01 を 1 と読み、先頭 BOM を飛ばすのは意味層の選択、Node が 20 桁整数を IEEE 754 倍精度に押し込むのは実行時の選択、4 経路が {"a":1,"a":2} の後勝ちに揃うのは主要パーサの共通慣習です。どれも「解析できた」には反しませんが、すべてデータを変えています。
JSON パースエラーの直し方:Unexpected token を順に切り分ける は構文層を扱い、エラー原文を座標に翻訳します。本稿はその手前、ツールがそもそもエラーを出さなければ座標すら得られない、という段階を扱います。
3. 静かな書き換え 4 種を個別に見る
4 件を抜き出すと、変更は肉眼で見落とすほど小さいものです。
{"a": 01} // 先頭ゼロ:jq は {"a":1} を出力
\ufeff{"a": 1} // BOM:jq は先頭 3 バイトを飛ばす
{"id": 9007199254740993} // 20 桁整数:JSON.parse は ...992 を出力
{"a": 1, "a": 2} // 重複キー:4 経路とも {"a":2} だけ残す
先頭ゼロと BOM は「入力が本来不正だが、ツールが寛容だった」型です。01 は標準 JSON では違法(RFC 8259 6 節)で、BOM も JSON テキスト先頭に置けません。Python と Node は拒否し、jq は続行します。ログ洗浄には便利ですが、契約検証では致命的です。上流は「不正データを送った、直す必要がある」と考え、下流は「普通に見える」結果を既に持っています。
20 桁整数は「入力は完全に合法だが、実行時が収め切れない」型です。9007199254740993 は合法な JSON 数値ですが 2^53-1 を超え、JSON.parse は 9007199254740992 しか表現できません。Python の int と jq は原値を保ちます。注文番号や Snowflake ID なら、末桁が 1 つ違うだけで照合が合わなくなります。
重複キーは「仕様が定めず、実装が各自選ぶ」型です。4 経路とも警告なしで後勝ち。RFC 8259 は一意性を SHOULD で述べるだけなのでバグではありませんが、設定の連結やパッチのマージで最も追いにくい類の問題です。
4. エラー報告:座標を出すもの、一行だけのもの
失敗した 6 件でも情報量は大きく違います。Python の JSONDecodeError は msg・lineno・colno・pos を同時に返し、Node v22 は position N (line 1 column M) と該当箇所の原文断片を添え、python -m json.tool は Python の情報を再利用します。jq は parse error: ... at line N の一行だけで、文字オフセットを出しません。
1 行に圧縮された JSON では行番号・桁番号はほぼ無意味です。必要なのは文字オフセットです。次のスクリプトは検証を「エラー+オフセット+前後文」に統一します。
import json, sys
def strict_check(text: str) -> None:
try:
json.loads(text)
print("OK")
except json.JSONDecodeError as e:
print(f"ERR {e.msg} | char {e.pos} | line {e.lineno} col {e.colno}")
lo, hi = max(0, e.pos - 30), min(len(text), e.pos + 30)
print("...", text[lo:hi].replace("\n", "\\n"), "...")
sys.exit(1)
strict_check(open(sys.argv[1], encoding="utf-8").read())
jq . より厳格(先頭ゼロと BOM を拒否)で、e.pos が得られます。line 1 column 23611 をバイト位置に換算する方法や、中文文書で文字オフセットとバイトオフセットが 3 倍違う理由は ミニファイされた JSON のデバッグ:1 行目 23611 列目から壊れたバイト位置へ に譲り、ここでは繰り返しません。
5. 直し方:整形を「厳格検証」+「美化」に分ける
結論は単純です。1 つの「整形」ボタンに検証と美化を兼ねさせないこと。まず厳格パーサで妥当性を判定し、それから排版方法を決めます。最初の一步は 1 行で切り出せます。
python3 -m json.tool input.json > /dev/null && echo "構文 OK" || echo "構文不正"
python3 -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); print(type(d).__name__, len(d))" input.json
1 行目は「解析できるか」だけに答え、2 行目で「期待した構造か」を確認します。大きな整数があるなら JSON.parse ではなく Python か jq で読み、BOM の可能性があるなら encoding='utf-8-sig' を明示し、キー重複の可能性があるなら解析前に別途走査します(4 経路とも警告しないため)。これらを CI に入れるのは エディタ、コマンドライン、自動化ツールチェーン の流路そのものに、データ忠実性のアサーションを 1 層足した形です。
6. 選び方:4 経路の比較表
| 経路 | 強み | リスク | 向いている場面 |
|---|---|---|---|
python json.loads / json.tool |
厳格、行列と文字オフセットを提示、整数の精度が落ちない | 先頭ゼロと BOM を拒否、エンコーディング指定が必要 | 契約検証、CI、ログ調査 |
node JSON.parse |
ブラウザ標準、エラーに原文断片が付く | 大きな整数を静かに丸める、BOM でエラー | フロントのデバッグ、ブラウザ内ツール |
jq -c . |
パイプ向き、最速で結果が出る | 先頭ゼロと BOM が静かに通る、文字オフセットなし | 運用パイプ、フィールド抽出 |
ブラウザツール /parse・/editor |
ツリー表示、キー整列、パスコピー、導入不要 | 挙動はページのパーサ次第、最初に 1 回検証が必要 | 目視確認、臨時トラブルシュート |
決め手は「機能が多いか」ではなく「自分のデータについて嘘をつかないか」です。注文 ID・金額・デバイス ID を含むなら Python か忠実性を明記したツールを、構造を見るだけのデバッグならブラウザが最も手軽です。本サイトの /parse はブラウザ内で構文検証と整形を行うため「まず形を見る」に向きますが、大きな整数は上記スクリプトで再確認してください。
7. プライバシーは境界の 1 つにすぎない
「データがアップロードされるか」はオンラインツールで最も聞かれる質問ですが、「データが書き換えられるか」とは独立しています。ローカルでオフライン動作するツールでも 20 桁整数は丸められますし、厳格にエラーを出すオンラインツールが原文をサーバへ POST することもあります。2 つは別々に検証すべきで、互いに代用できません。プライバシー FAQ の 2 分で終わる 3 つの確認に対し、本稿が補うのは「ツールが静かに値を変えないか」を下のサンプルで確かめる半分です。
8. コピーできる自己点検リスト
S1〜S10 を 1 行ずつ cases.txt に保存し、候補ツールに貼り付けて 3 点を確認します。失敗すべきサンプルが実際に失敗するか、失敗時に具体的な位置が出るか、成功したサンプルの出力が入力と 1 文字も違わないか。特に S4・S6・S7・S8 を重点的に見て、SILENT が 1 行でもあればその経路は単独でデータ検証に使えません。
さらに 3 つの工学的制約を足します。厳格検証を pre-commit フックに入れ、本番で整数の不一致に気づくのを防ぐ。多言語環境ではファイルを常に BOM なし UTF-8 で書く。第三者由来の JSON は生レスポンスを保持し、整形結果は閲覧専用にして書き戻しに使わないこと。
よくある質問
整形が通れば JSON は問題なしと言えますか?
いいえ。S4・S6・S7・S8 はすべて整形に「成功」しましたが、先頭ゼロ・BOM・20 桁整数・重複キーが書き換えまたは除去されています。整形は「あるパーサが受け付けた」ことしか証明せず、値が送信値と等しいことは証明しません。重要フィールドは値ごとにアサーションを置いてください。
なぜ jq は Python が拒否する先頭ゼロや BOM を受け付けるのですか?
jq は独自の字句解析器を持ち、01 のような数字に寛容で、先頭 BOM も読み飛ばします。Python と Node は RFC 8259 に厳格に従います。バージョンのバグではなく、寛容パーサと厳格パーサの取舍です。洗浄では前者が省力、契約検証では後者が必須です。
大きな整数が 9007199254740992 になるのを防ぐには?
JavaScript の Number は IEEE 754 倍精度で、安全な整数の上限は 2^53-1。20 桁 ID を JSON.parse で読めば必ず丸まります。こうした ID は文字列で保存するか、Python の int、jq、BigInt 対応パーサを使ってください。フロントで数値が必要なら、解析前にそのフィールドを文字列に置き換えます。
オンラインツールとローカル CLI、どちらを使うべきですか?
データの機微度と用途で分けます。構造確認やフィールド探索ならブラウザが最速、パイプラインや忠実性のアサーションなら python -m json.tool と厳格チェックです。どちらも先に S1〜S10 を走らせ、エラーと出力が想定通りか確かめてから、どの種類のデータを入れるか決めてください。