JSON Toolkit

ミニファイされた JSON のデバッグ:1 行目 23611 列目から壊れたバイト位置へ

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

上級約 7 分で読めます2026-11-01 公開2026-11-01 更新
ミニファイされた JSON のエラー位置を特定する記事のカバー画像

要点:行番号と列番号は複数行の文書でしか意味を持ちません。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 つの注意点:

  1. すべてをブラウザ内で処理すると明示しているツールを選び、内容をバックエンドへ POST するツールは選ばない;
  2. 貼り付けは非常に大きなファイルにも耐えますが、数十メガバイトを超えるとタブ自体がメモリを食います——その時点で方法 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 が解析後に実行されると明記しており、下流では何も復元できません。唯一信頼できる修正は、生成側が文字列として出力することです。