ミニファイされた JSON のデバッグ:1 行目 23611 列目から壊れたバイト位置へ
要点:4 MB の 1 行 JSON が「1 行目 23611 列目」と報告しても何の助けにもなりません。ローカルで実測したサンプルをもとに、Python・Node・jq がなぜ 3 つの異なる数値を出すのか、文字オフセットでどう壊れたバイトへ直行するのか、そして文法修正後にデータを静かに壊す 3 つの罠を解説します。

要点:行番号と列番号は複数行の文書でしか意味を持ちません。JSON が 1 行にミニファイされると、すべてのエラーは 1 行目に集約されます。必要なのは行番号ではなく、文字インデックスです。
ミニファイされた JSON 文書は 1 行しかないため、エラーメッセージの行・列ペアは 400 万バイトの位置を無意味な一語に押し込めます。回避策は行・列を完全に無視して文字オフセットを読むことです。メッセージ末尾の控えめな
(char 23610)こそ、実際にあなたを救う数値です。
以下の数値はすべてローカルで生成したものです。サンプルは 13,000 件の請求イベントを含む 1 行の文書で、合計 2,011,084 文字です。イベント #154 の event_id の後ろにカンマを 1 つ故意に削除しています。環境は Python 3.11.1、Node 22.13.1、jq 1.7 です。
1. まず事実から:3 つのパーサー、3 つの数値
同じ壊れたファイルを 3 つのツールに渡します:
Python 3.11.1 Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node 22.13.1 Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq 1.7 Expected separator between values at line 1, column 23619 exit code 5
並べてみます:
| ツール | 報告される値 | 単位 | 起点 | 行・列が役に立たない理由 |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | 文字オフセット | バッファ先頭 | 唯一信頼できる値 |
Python lineno / colno |
1 / 23611 | 行と列 | 行頭 | 1 行しかないので何も語らない |
Node position |
23610 | 文字オフセット | バッファ先頭 | 信頼できるが、メッセージはフィールドを隠す |
jq column |
23619 | 文字オフセット | 問題トークンの先頭 | Python と 9 ずれる |
結論は明快です。Python の char N と Node の position N は同じ数値で、ぴったり一致します。jq は独自の方式を使います——それを突き合わせるのに時間を浪費しないでください。
3 つの数値が異なるのは、誰かが計算を間違えたからではなく、数えているものが違うからです。Python と Node はパーサーが止まった場所を報告し、jq は解析に失敗したトークンが始まる場所を報告します。両者の間にはすでに消費された文字がいくつかあり、このサンプルでは 9 文字です。
2. なぜこの種のファイルでは「1 行目 N 列目」が雑音になるのか
行番号は「最後の改行からしばらく経った」ことを前提とする派生量です。ミニファイされた文書には \n が 1 つ、たいてい末尾にしかないため:
linenoは常に 1;colnoは「その 1 行の先頭から何文字目か」に退化する;- そしてその行は 400 万文字もの幅がある。
エディタで 23611 列目へジャンプしても、役に立つものは何も見えません。さらに悪いことに、less や vim のようなツールは極端に長い行では這うようにしか動かず、矢印キーでカーソルを少し動かすだけで 20 分を費やします。
逆に捉えてください。オフセットこそが本来の量で、行・列はそこから計算される派生値です。 本来の量を使ってください。
3. 方法その 1:Python では e.pos を使い、lineno を無視する
JSONDecodeError は 3 つの数値を持ちますが、読む価値があるのは pos だけです。lineno と colno は、1 行の文書では何も残らない派生値です。
# locate.py - Python 3.11.1
import json
import sys
path = sys.argv[1]
raw = open(path, encoding='utf-8').read()
try:
json.loads(raw)
except json.JSONDecodeError as error:
print(f'{type(error).__module__}.{type(error).__name__}: {error}')
start = max(0, error.pos - 60)
end = min(len(raw), error.pos + 60)
print(f'\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
print('valid JSON')
壊れたファイルに対して実行します:
$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)
char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
^
キャレットはカンマが欠けている場所を指します。20 分が 1 コマンドになります。
ウィンドウ幅はどれくらいにすべきか: ±60 文字はほぼ常に完全なキー/値ペアを覆います。狭すぎると文脈を失い、広すぎると画面が埋まります。深くネストした文書では ±120 まで広げられます。
4. 方法その 2:コンテナに Python がない——jq と cut を使う
本番コンテナには Python が入っていないことがよくありますが、jq はほぼ必ずあり、apt-get install jq 一発で入手できます。jq が与えるのは明確な失敗です:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
終了コード 5 は入力が不正という意味なので、set -e の下では grep が 1 を返すときと同じようにスクリプトを中断します。これは利点です。
jq には問題のバイトを出力するフラグがないので、位置を cut に渡します:
# Note -c (characters), not -b (bytes)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
カンマを戻せば、jq は本来得意なことをできます:
$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479
5. 方法その 3:ランタイムがない、またはデータをディスクに触れさせられない——ブラウザに貼り付ける
最初の 2 つの方法は、何かがインストールされていて、ファイルを書き込む権限があることを前提にします。裸のノート PC を使っている場合や、ペイロードが顧客のメールアドレスと IP アドレスで埋まっていて、「どこかのサイトに貼り付けました」という一文をインシデントレポートに書きたくない場合は、3 つ目の選択肢が向いています。
文字列全体をオンライン JSON パーサーに貼り付けると、問題の文字にキャレットを置き、行と列を表示します。ローカルツールの利点は、その 4 MB がマシンから出ず、他人のログにも残らないことです。
2 つの注意点:
- すべてをブラウザ内で処理すると明示しているツールを選び、内容をバックエンドへ POST するツールは選ばない;
- 貼り付けは非常に大きなファイルにも耐えますが、数十メガバイトを超えるとタブ自体がメモリを食います——その時点で方法 1 に戻ってください。
6. 文字オフセットはバイトオフセットではない:CJK データでの第 2 の罠
これは中国語と英語が混在するペイロードで、両方の方法を刺します。
JSONDecodeError.pos は文字オフセット(Unicode コードポイントの個数)です。str を解析した結果だからです。cut -c は文字単位で切り、それと一致します。しかし head -c、tail -c、dd はバイト単位で切り、UTF-8 では 1 回ずれるだけで全部が狂います。
中国語のサンプルで具体化します(注文 5 件、148 文字、222 バイト):
>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35] # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35] # bytes 25-35
b'DD00000","'
同じオフセットでも、文字ビューはフィールド名に当たり、バイトビューは注文 ID の末尾に当たります。CJK 文字 1 つは UTF-8 で 3 バイトを占め、ずれは位置が進むほど蓄積します。
ルール: e.pos を読んだら cut -c か Python のスライスで切り、head -c / dd bs=1 skip=N は決して使わない。バイト系ツールしかない場合は先に変換します:len(raw[:error.pos].encode('utf-8'))。
7. 文法を直してもデータは間違ったまま:3 つの静かな罠
これこそ、デバッグが「解析できた」で止まれない理由です。次の各項目は、例外をまったく出さないか、値が使われたずっと後にしか出しません。
罠 1:重複キー——最後が勝ち、誰も文句を言わない
RFC 8259 の第 4 節は明確です。オブジェクト内の名前は一意であるべき(SHOULD)で、そうでない場合の挙動は予測できません。ほとんどの実装は最後の出現を残し、一部は例外を投げます。ここで測定した 3 つのツールはすべて最後を残します:
Input {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node {"order_id":"A-1002","total":4200}
Python {'order_id': 'A-1002', 'total': 4200}
jq {"order_id":"A-1002","total":4200}
警告は 1 つもありません。上書きされたフィールドが amount_cents だったなら、突き合わせが 3 セントずれたときに初めて気づきます。
罠 2:NaN と Infinity——Python は受け入れるが Node は拒否する
RFC 8259 の第 6 節は、数値リテラルに NaN も Infinity も含まれないと明言しています。Python の json.loads は、Python 自身の float にこれらの値があるため、既定で両方を受け入れます:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
その出力を Node に渡すと、こうなります:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
つまり:不正なデータは Python を通過し、下流の消費者で爆発します。
罠 3:2^53 を超える整数は静かに精度を失う
1 つの文書、2 つの異なる値:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
理由を 1 行で言うと、JavaScript の Number.MAX_SAFE_INTEGER は 9007199254740991(2^53-1)です。MDN は、reviver が値の解析後に実行される後であると特に指摘しているため、精度は reviver より前に失われ、デシリアライズ側では復元できません。唯一の修正は、生成側が大きな ID を文字列としてシリアライズすることです。
3 つの罠を比較します:
| 罠 | 例外は出るか | 最初に気づくのは誰か | どう intercept するか |
|---|---|---|---|
| 重複キー | 出ない | 下流の突き合わせ | object_pairs_hook による明示的な検査 |
| NaN / Infinity | Node は出す、Python は黙る | 下流の消費者 | parse_constant で境界で拒否 |
| 2^53 を超える整数 | 出ない | 等値比較を行うコード | 文字列として送る |
最初の 2 つを 1 回で捕まえる
# check.py - Python 3.11.1
import json
import sys
from collections import Counter
def no_dupes(pairs):
counts = Counter(key for key, _ in pairs)
dupes = sorted(key for key, n in counts.items() if n > 1)
if dupes:
raise ValueError(f'duplicate key(s): {dupes}')
return dict(pairs)
def reject_constant(name):
raise ValueError(f'non-standard JSON constant: {name}')
raw = open(sys.argv[1], encoding='utf-8').read()
try:
data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
except ValueError as error:
print(f'rejected: {error}')
sys.exit(1)
print(f'valid JSON, {len(raw)} chars')
コストを正直に言うと、同じ 2,011,084 文字のサンプルで、この厳格版は 35.2〜36.2 ms、緩い版は 9.4〜9.9 ms、およそ 3.7 倍遅い(各 5 回×5 ラウンド、ラウンドごとの中央値で 3.6〜3.8 倍)——object_pairs_hook がオブジェクトごとに Counter を実行するためです。CI の正常系からは外し、配送物が怪しいときにだけ切り替えてください。
8. チェックリスト
ファイルを直したあとは、読みやすさのために整形したくなりますが、同じサンプルはミニファイ時(separators=(',',':'))が 2,011,084 文字、indent=2 では 2,869,100 文字で 43% 増えます。その重さは Git 履歴、CI キャッシュ、ネットワーク転送へと流れるので、整形版はローカルに留め、出荷前に 1 行へ戻してください。
- [ ] まず
(char N)/position Nを探し、line 1 column Nは無視する; - [ ] Python は
e.pos、jq はcut -cと組み合わせる——同じ文字オフセットなので、jq のcolumnと突き合わせない; - [ ] CJK 文書は文字単位で切る(
cut -c、Python のスライス)。決してhead -c/ddを使わない; - [ ] コンテナスクリプトでは jq の終了コード 5 を「入力不正」として扱う;
- [ ] 怪しいデータは
object_pairs_hook+parse_constantで再確認し、約 3.7 倍のコストを受け入れる; - [ ] 2^53 を超える ID は文字列として送る。reviver が救ってくれることを期待しない;
- [ ] 整形済み出力(実測 +43%)はローカルに留める:出荷やコミットの前に /minify で 1 行に戻し、数 MB を超える場合はストリーミングに切り替える。詳しくは大きな JSON の解析・描画・ストリーミングを参照。
FAQ
3 つのツールがすべて異なる位置を報告するのはなぜですか?
数えているものが違うからです。Python の char N と Node の position N はどちらも「パーサーが止まった場所」を意味し、このサンプルでは両方 23610 で一致します。jq は問題のトークンが始まる位置を報告し、ここでは 23619 で 9 文字先です。誰も計算を間違えておらず、補正も不要です。同じツール内でのみ数値を比較してください。
cut -c と head -c のどちらを使うべきですか?
cut -c を使ってください。JSONDecodeError.pos は Unicode 文字で数えたオフセットで、cut -c は文字単位で切るため、2 つは一致します。head -c、tail -c、dd はバイト単位で切り、CJK テキストを含む UTF-8 文書ではずれが生じ、文字を半分に切ることさえあります。中国語サンプルでは、同じオフセットで文字ビューは 城市":"杭州"},、バイトビューは DD00000"," になります。
重複キーの扱いは本当にどこでも無防備なのですか?
はい。RFC 8259 の第 4 節は名前が一意であるべき(SHOULD)とだけ述べ、一意でない場合の挙動は予測できないとしています。ここで測定した Python、Node、jq はすべて警告なしで「最後が勝つ」方式を適用します。捕まえるには独自の object_pairs_hook が必要で、解析時間が約 3.7 倍になります。
Python は NaN を問題なく読み、Node は例外を投げるのはなぜですか?
RFC 8259 の第 6 節は NaN と Infinity を数値リテラルとして認めていません。Python の json.loads は Python 自身の float がこれらの値を持つため既定で受け入れ、不正なデータは Python を通過してそのまま書き出されます。Node の JSON.parse は仕様に厳密に従い、その出力に対して SyntaxError を投げます。入口で parse_constant を追加すれば即座に拒否できます。
20 桁の注文番号は JSON 経由で必ず精度を失いますか?
消費者がそれを IEEE 754 の倍精度として解析する場合だけです。Python の int は任意精度で 12345678901234567890 を保ちますが、JavaScript の Number.MAX_SAFE_INTEGER は 9007199254740991 なので、同じフィールドは 12345678901234567000 になります。MDN は reviver が解析後に実行されると明記しており、下流では何も復元できません。唯一信頼できる修正は、生成側が文字列として出力することです。