JSON 工具箱

單行 JSON 報錯怎麼定位:從 line 1 column 23611 找到出錯的那個位元組

結論:4MB 單行 JSON 報 line 1 column 23611,行列號形同虛設。本文用本機實測的三份樣本,解釋 Python、Node、jq 為什麼報出三個不同的數字,怎麼用字元偏移一次挖到出錯的位元組,以及語法修好之後仍然會悄悄丟資料的三類陷阱。

實戰約 7 分鐘發佈於 2026-11-01更新於 2026-11-01
單行 JSON 報錯定位封面

結論:報錯裡的行列號只有在多行文件裡才有意義。JSON 一旦被壓成一行,所有的錯誤都住在 line 1,你要找的不是第幾行,而是第幾個字元。

壓過的 JSON 只有一個「行」,報錯裡的 line / column 是把一個四百萬位元組的位置摺疊成的一句話。可行的做法是跳過行列號,直接讀字元偏移——那個不起眼的 (char 23610) 才是真正能救命的數字。

下面所有數字都是本機實跑出來的:樣本是一份 13,000 筆帳單事件的單行 JSON,共 2,011,084 個字元,在第 154 筆事件的 event_id 後面故意刪了一個逗號。執行環境 Python 3.11.1、Node 22.13.1、jq 1.7。

一、先看事實:三個解析器給出三個數字

同一份壞檔案餵給三個工具:

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     退出碼 5

把它們放到一張表裡:

工具 給的位置 單位 基準點 失效原因
Python JSONDecodeError.pos 23610 字元偏移 緩衝區起點 唯一可靠的那個
Python lineno / colno 1 / 23611 行列 行 文件只有一行,等於沒給
Node position 23610 字元偏移 緩衝區起點 可靠,但報錯文案裡不直接暴露這個欄位
jq column 23619 字元偏移 出錯 token 的起點 與 Python 差 9,來自計數基準不同

結論很直接:Python 的 char N 和 Node 的 position N 是同一個數,可以對得上;jq 的那個數字自成一套,別花時間去對齊它。

這三個數字之所以不同,不是誰算錯了,而是它們數的東西不一樣。Python 和 Node 報告的是「解析器停下來的地方」,jq 報告的是「那個沒被解析好的 token 的開頭」。兩者之間隔著若干個已讀字元,本樣本裡是 9 個。

二、為什麼 line 1 column N 在這種檔案上等於廢話

行號是一個「很久沒有換行符」的派生量。壓過的 JSON 只有一個 \n(通常在檔案末尾),於是:

  • lineno 恆為 1;
  • colno 退化成一個「從行首數到第 N 個字元」的計數;
  • 而這一「行」有四百萬字元寬。

所以編輯器裡跳到第 23611 列,你什麼都看不到。更糟的是 less、vim 這類工具在超長行上互動極慢,用方向鍵一個個挪過去,二十分鐘就這麼沒了。

反過來想:偏移才是原生量,行列是從偏移算出來的。既然如此,直接用原生量就行。

三、方法一:Python 用 e.pos,別讀 lineno

JSONDecodeError 身上掛著三個數字,只有 pos 有用。lineno、colno 都是從 pos 派生、並在單行文件上退化的贗品。

# 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",
                                                            ^

箭頭指著的位置缺一個逗號。整件事從二十分鐘變成一條命令。

視窗取多大:±60 個字元幾乎總能覆蓋一對完整的鍵值。太小會切掉上下文,太大會糊滿整屏。巢狀很深的文件可以放寬到 ±120。

四、方法二:容器裡沒 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:

# 注意是 -c(按字元),不是 -b(按位元組)
$ 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

五、方法三:沒環境、或者資料不能落盤,就貼進瀏覽器

前兩種要求機器上有東西、並且允許你把檔案寫進某個目錄。當你手上是一台什麼都沒裝的筆電,或者這份資料裡全是客戶信箱和 IP、「貼到某個網站」是事故報告裡不想寫進去的一句話時,第三種方式最合適。

把整段貼進 JSON 線上解析,它會把游標直接停在出錯字元上並標出行列;本機工具的好處是這 4MB 資料不會離開這台機器,也就不會出現在別人的紀錄裡。

要注意兩點:

  1. 選擇明確寫著「瀏覽器內處理」的工具,別用會把內容 POST 到後端的產品;
  2. 瀏覽器貼上對超大檔案友善,但超過幾十 MB 時分頁本身會吃記憶體,這時候回到方法一。

六、字元偏移不等於位元組偏移:中文文件的第二個坑

這是前兩種辦法在中英混合資料上都會踩到的雷。

JSONDecodeError.pos 是字元偏移(Unicode 碼點數),因為它是對 str 解析的結果。cut -c 按字元切割,與之對齊;但 head -c、tail -c、dd 按位元組切割,在 UTF-8 下一錯位就全錯。

用一份中文樣本說明(5 筆訂單,148 個字元、222 個位元組):

>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35]                                    # 第 25~35 個【字元】
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # 第 25~35 個【位元組】
b'DD00000","'

同一個偏移量,字元視角切出來是欄位名,位元組視角切出來是訂單號的尾巴。一個漢字在 UTF-8 裡佔 3 位元組,誤差隨位置累積。

規則:拿到 e.pos 之後,用 cut -c 或 Python 切片,不要用 head -c / dd bs=1 skip=N。如果只能用位元組工具,先顯式換算:len(raw[:error.pos].encode('utf-8'))。

七、語法修好了,資料還是錯的:三類靜默陷阱

這也是為什麼除錯不能停在「解析成功」上。以下三種情況要麼完全不報錯,要麼報錯的時機遠在使用之後。

陷阱 1:重複鍵,後者勝,無人報警

RFC 8259 第 4 節寫得很清楚:物件名稱 SHOULD 唯一;名稱不唯一時行為不可預測,多數實作只保留最後一個,也有實作直接報錯。實測三個工具都是後者勝:

輸入    {"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}

一條警示都沒有。如果那個被覆蓋的是 amount_cents,你會在對帳單差了三分錢的時候才知道。

陷阱 2:NaN 與 Infinity,Python 收得下、Node 讀不進

RFC 8259 第 6 節明確規定數字字面量裡沒有 NaN 和 Infinity。但 Python 的 json.loads 預設接受它們,因為它自己的 float 有這兩個值:

>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}'    # 原樣寫回

這份輸出交給 Node 就是:

SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON

也就是:壞資料過了 Python 這一關,然後在下游消費者手上炸掉。

陷阱 3:超過 2^53 的整數,精度靜默丟失

同一份資料在兩種語言裡是兩個值:

JSON 原文   {"order_no":12345678901234567890}
Python      12345678901234567890          ← 原樣保留
Node        12345678901234567000          ← 末三位被抹平

原因寫成一行:JavaScript 的 Number.MAX_SAFE_INTEGER 是 9007199254740991(2^53-1)。MDN 特別指出,reviver 是在值已經被解析成 JavaScript number 之後才執行的,所以精度損失發生在 reviver 之前——在反序列化側救不回來,只能讓生產方把大 ID 序列化成字串。

三類陷阱對照:

陷阱 是否有報錯 誰會先發現 攔截方式
重複鍵 無 下游對帳 object_pairs_hook 顯式檢查
NaN / Infinity Node 側報錯,Python 側無 下游消費者 parse_constant 在入口拒絕
超過 2^53 的整數 無 做等值比較的程式碼 資料側改字串傳輸

一次攔住前兩種

# 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 字元的樣本上,本機跑 5 輪、每輪 5 次取中位數,寬鬆版落在 9.4~9.9 ms,嚴格版落在 35.2~36.2 ms,也就是慢約 3.7 倍(區間 3.6~3.8)——因為 object_pairs_hook 對每個物件都要跑一次 Counter。所以別放進 CI 的 happy path,只在收到可疑投遞時切換過去。

八、檢查清單

修好之後通常順手美化一份便於閱讀,但同一份樣本實測:單行(separators=(',',':'))2,011,084 字元,indent=2 美化後 2,869,100 字元,膨脹 43%。這份體積會一路帶進 Git 歷史、CI 快取和網路傳輸,所以美化版只在本機停留,下發或提交前壓回單行。

  • [ ] 報錯裡優先找 (char N) / position N,忽略 line 1 column N;
  • [ ] Python 用 e.pos,jq 用 cut -c,兩者是同一套字元偏移,jq 的 column 不要拿去對齊;
  • [ ] 中文文件用字元切割(cut -c、Python 切片),絕不用 head -c / dd;
  • [ ] 容器腳本依賴 jq 退出碼 5 判定輸入不合法;
  • [ ] 可疑資料用 object_pairs_hook + parse_constant 複檢,接受約 3.7 倍的耗時;
  • [ ] 超過 2^53 的 ID 一律字串傳輸,不要指望 reviver 救回來;
  • [ ] 美化版(實測 +43% 體積)只在本機停留,下發或提交前用 /minify 壓回單行;幾 MB 以上改用串流解析,見大 JSON 的解析、渲染與串流處理。

常見問題

為什麼三個工具報的位置都不一樣?

因為三者數的東西不同。Python 的 char N 和 Node 的 position N 都是「解析器停下的位置」,實測同一份樣本上兩者都是 23610,可以對上。jq 報的是「出錯 token 的起始位置」,本樣本為 23619,比前者多 9。這不是誰算錯,也不需要校正,只在同工具內部縱向對比即可。

cut -c 和 head -c 到底該用哪個?

用 cut -c。JSONDecodeError.pos 是按 Unicode 字元計的偏移,cut -c 同樣按字元切割,兩者對齊。head -c、tail -c、dd 按位元組切割,在含中文的 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 預設放寬接受,因為它自身的 float 有這兩個值,於是壞資料能過 Python 這一關並被原樣寫回。Node 的 JSON.parse 嚴格按規範執行,拿到這份輸出就拋 SyntaxError。入口加 parse_constant 可以第一時間拒絕。

20 位訂單號用 JSON 傳一定會丟精度嗎?

只在消費側把它解析成 IEEE 754 雙精度浮點時丟。Python 的 int 是任意精度,實測保留 12345678901234567890;JavaScript 的 Number.MAX_SAFE_INTEGER 是 9007199254740991,同一欄位解析為 12345678901234567000。MDN 明確指出 reviver 執行在解析之後,救不回來,唯一可靠的做法是讓生產方以字串輸出。