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}

一条 alert 都没有。如果那个被覆盖的是 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 运行在解析之后,救不回来,唯一可靠的做法是让生产方以字符串输出。