Python JSON 实测:三个库给出三种输出
同一份 dict 交给三个库,会得到三种 JSON。这不是修辞:我实际测试了同一份 {"id": 1, "name": "测试"},分别喂给标准库 json、orjson 3.13.0 和 msgspec 0.22.0,输出的字节数、键的形态、甚至数字的写法都不一样,而且其中四处不会报任何错。
所以我做了一件事:把日常最容易踩的九处默认值摆成对照表,逐项实跑取数,再把这些「静默」的行为各配一套可以复现的最小用例。环境是本机 Windows 11 + CPython 3.13.14,orjson 与 msgspec 都用 pip 装在隔离目录,没动主环境。下面每个数字都是实际跑出来的,不是抄文档。本文不含任何联盟推广链接。
先把层差说清楚:站内 50,000 条记录的四格式实测 回答的是「该选哪种线格式」,本篇不重复那张体积榜,只回答一件事——同一个 dict 从标准库换到另外两个库之后,哪些默认值变了,哪几处变化不报错。
TL;DR:换库之前先确认三件事——① 你的数据里有没有 NaN(orjson 与 msgspec 会静默写成 null,标准库写出的则是非法 JSON);② 你的时间字段是不是 datetime.now() 这种无时区对象(两个新库都不加偏移量,读取方无从判断时区);③ 你的 dict 键是不是全是 str(int 键进去,str 键出来,往返不再相等)。三条里中任意一条,先补转换再谈性能。
同一份数据,三种输出
最小用例先看返回类型:
json.dumps返回str,中文默认被转义成\u6d4b\u8bd5orjson.dumps返回bytes,中文按 UTF-8 原样输出msgspec.json.encode也返回bytes,同一条 payload 与 orjson 逐字节相同(实测比较结果为真)
返回类型这条差异看起来只是写法问题,但它会在真实代码里直接炸:把 orjson 的结果拼进字符串,得到的是 TypeError: can't concat str to bytes;用文本模式写文件,得到的是 TypeError: write() argument must be str, not bytes,而且那个文件已经被创建成 0 字节——我第一次踩的时候,后续的读取脚本把它当成「空的合法 JSON」处理了。
九处默认值,一张表看完
| 差异点 | 标准库 json | orjson 3.13.0 | msgspec 0.22.0 |
|---|---|---|---|
| 返回类型 | str | bytes | bytes |
| 中文是否转义(ensure_ascii 默认值) | 转义,288 B | 不转义,156 B | 不转义,156 B |
| NaN 与 Infinity 编码 | 原样写出 NaN、Infinity | 转成 null | 转成 null |
解析 NaN、1e400 | 接受,得到 nan、inf | 抛 JSONDecodeError | 抛 DecodeError 或 ValidationError |
| datetime 对象 | 抛 TypeError | 输出 RFC 3339 字符串 | 输出 RFC 3339 字符串 |
| 无时区 datetime | 不适用 | 不带偏移量 | 不带偏移量 |
| 非 str 的 dict 键 | 静默转成 str | 抛 TypeError,需 OPT_NON_STR_KEYS | int 与 float 静默转 str,bool 与 None 抛 TypeError |
| 超过 64 位的整数 | 支持 | 抛 TypeError | 支持 |
| 解码进 Struct 时的未知字段 | 不适用 | 不适用 | 默认丢弃 |
表格里最右边一列有四个「静默」,最左边一列有三个。下面逐条说清后果。
静默一:NaN 变成 null
json.dumps 对 NaN 的态度是照抄 JavaScript:写出字面量 NaN。Python 官方文档写得很清楚——「The RFC does not permit the representation of infinite or NaN number values. Despite that, by default, this module accepts and outputs Infinity, -Infinity, and NaN as if they were valid JSON number literal values」,紧接一句「Neither of these calls raises an exception, but the results are not valid JSON」。
orjson 选了另一条路:把 NaN、Infinity、-Infinity 都写成 null,官方 README 的原话是输出「compliant JSON, as null」。msgspec 与它一致,实测三个值全部变成 null。
这就形成了一个跨库断点:json 写出的 NaN 交给 orjson 或 msgspec 解析,两边都直接拒绝——orjson.JSONDecodeError: unexpected character, expected a JSON value: line 1 column 10 (char 9),msgspec.DecodeError: JSON is malformed: invalid character (byte 9)。而 json 自己解析 NaN 是接受的,因为它的解析器本来就宽容。
后果在批量数据里更明显。5,000 行里埋 5 个 NaN,orjson 往返之后 5 个全部变成 null(json 往返仍然保持 5 个 NaN)。下游拿到 null 只会当成「缺失」,不会当成「无效数值」,指标统计里这几个点就这么静默消失。如果你的聚合链路依赖「非数字要被发现」,两个新库都需要自己先扫一遍。
顺带一个更隐蔽的:json.loads('1e400') 返回 inf,不报错;orjson 报 number is infinity when parsed as double,msgspec 报 ValidationError: Number out of range。同一份脏数据,三个库三种命运。
静默二:没有时区的 datetime 就这么写出去了
这一条是我认为最值得单独立规矩的。json.dumps 遇到 datetime 直接抛 TypeError: Object of type datetime is not JSON serializable——难看,但安全。orjson 与 msgspec 都原生支持,把带时区的对象写成 RFC 3339:
- 带
+08:00的对象:两个库都输出2026-10-28T20:44:05.123456+08:00 - 带 UTC 的对象:orjson 输出
+00:00,msgspec 输出Z(orjson 要Z得显式加OPT_UTC_Z) datetime.now()这种无时区对象:两个库都原样写出,不带任何偏移量——2026-10-28T20:44:05.123456
第三条是坑。同一份「无时区」字符串,写入方在国内以为它是北京时间,读取方在 UTC 机器上按 UTC 解析,8 小时就没了,而且整条链路零报错。msgspec 官方迁移文档把这件事写明了:「By default, both libraries encode naive datetimes identically (without timezone info)」,并且写了 orjson 有 OPT_NAIVE_UTC 可以把无时区对象按 UTC 处理,而「msgspec has no equivalent flag」——官方给的做法是在编码前自己 replace(tzinfo=timezone.utc)。
文档写了,但没人读。我的规矩是:只要用这两个库,就把「禁止出现无时区 datetime」写成代码检查,而不是靠自觉。
静默三:int 键变 str,往返不再相等
{1: "a", 2: "b"} 这个 dict:
json.dumps直接给'{"1": "a", "2": "b"}',不报错orjson.dumps抛TypeError: Dict key must be str,加option=orjson.OPT_NON_STR_KEYS才行msgspec.json.encode静默给b'{"1":"a","2":"b"}'——注意它和 orjson 不一样,int 与 float 键它自己就转了
往返之后键全是 str,loads(dumps(x)) != x。Python 官方文档对这条有专门一句:「if a dictionary is converted into JSON and then back into a dictionary, the dictionary may not equal the original one. That is, loads(dumps(x)) != x if x has non-string keys.」
真正难查的是「一半库拒绝、一半库静默」这种不对称。同样是 int 键,换库时 orjson 会立刻抛错提醒你,msgspec 和标准库一起沉默。更大的坑在 bool 与 None 键:标准库照样转成 "true"、"null",orjson 加 OPT_NON_STR_KEYS 也转,msgspec 却抛 TypeError: Only dicts with str-like or number-like keys are supported——同一个字典,三个库两种结局。
静默四:msgspec 默认把未知字段扔掉
这一条跟安全相关,值得单列。把 {"id": 7, "name": "kim", "role": "admin", "quota": 999} 解码进一个只有 id、name 两个字段的 msgspec.Struct,结果是 User(id=7, name='kim')——role 和 quota 消失了,没有异常,没有警告。
官方文档原话是「By default msgspec will skip unknown fields encountered when decoding into」Struct,还举了一个拼错字段名被忽略的例子。要拦住它得显式声明 forbid_unknown_fields=True,此时才会抛 msgspec.ValidationError: Object contains unknown field role``。
反过来说,这也是它的价值所在:类型检查是严格的。{"id": "7"} 解码进 int 字段会抛 ValidationError: Expected int, got str - at $.id——带字段路径,比标准库那种「dict 里全凭运气」的宽松好用太多。{"id": true} 也会被拒(Expected int, got bool),而 Python 里 isinstance(True, int) 是 True`,标准库路线根本发现不了这种冒充。
不静默的那部分:orjson 的两条硬边界
orjson 的整数上限写在 README 里:「The range supported is a signed 64-bit integer's minimum (-9223372036854775807) to an unsigned 64-bit integer's maximum (18446744073709551615)」。实测 pow(2, 64) 直接抛 TypeError: Integer exceeds 64-bit range,而标准库与 msgspec 都能写出 30 位整数。
如果你的 ID 是「超长数字字符串当数字存」那种脏数据,换 orjson 会在编码阶段炸——这比静默出错好,但迁移前要扫一遍。
性能:5 万行实测
50,000 行 id、name、score、active 四字段,编码取 5 次平均、解码取 5 次平均:
| 库 | 编码 | 解码 | 输出体积 |
|---|---|---|---|
| 标准库 json | 59.3 ms | 60.2 ms | 3,399 KB |
| orjson 3.13.0 | 8.6 ms(6.9 倍) | 33.4 ms(1.8 倍) | 3,008 KB |
| msgspec 0.22.0 | 17.1 ms(3.5 倍) | 33.1 ms(1.8 倍) | 3,008 KB |
体积差 11.5% 来自分隔符与中文转义:标准库默认 ', ' 与 ': ' 带空格,且 ensure_ascii=True 把中文全转成 \uXXXX。单看中文那一段,288 B 对 156 B,差 81.1%(这是一段几十字的中文,纯 ASCII 数据不会有这么大差距)。
解码只快 1.8 倍,这是很多人换完库觉得「没感觉」的原因——真正拉开差距的是 msgspec 的类型化解码:同一个 payload 直接解成 list[Row](7 次取最好值)20.0 ms,而 json.loads 拿到纯 dict 就要 57.6 ms,还没算你自己写的字段校验。2.9 倍的差距主要来自「边解析边校验」省掉了二次遍历。
需要说明口径:以上都是单机单次会话的对照,只用于判断量级,不构成跨机器结论;CPU、payload 形态、字段数都会改变比例。
踩坑录:五个真实报错
报错一:TypeError: can't concat str to bytes
把 orjson.dumps(data) 拼进日志字符串。原因是没有 .decode()。修法二选一:统一 .decode('utf-8'),或者把日志拼接受体也换成 bytes 模板。同理,json.loads(orjson.dumps(x)) 是可以的(json 接受 bytes),orjson.loads(json.dumps(x)) 也接受 str——两个方向都不对称,别指望记住,直接定一条团队规则。
报错二:TypeError: write() argument must be str, not bytes
open(path, 'w') 之后写 orjson 结果。文件会被创建成 0 字节,异常抛出后如果不清理,下一次运行读取脚本读到空文件,会得到「合法但为空」的假象。修法:open(path, 'wb'),或者在写之前先断言 isinstance(payload, bytes)。
报错三:TypeError: Integer exceeds 64-bit range
上面那条边界。修法:ID 一律转 str 再序列化,或者改用 msgspec(它没有这个限制)。
报错四:msgspec.ValidationError: Expected `int`, got `str` - at `$.id`
上游把数字发成了字符串。好消息是错误里带了路径 $.id;坏消息是很多人这样写:
try:
obj = msgspec.json.decode(raw, type=User)
except msgspec.DecodeError:
log.warning("payload 格式错误")
ValidationError 是 DecodeError 的子类(实测 issubclass 为真),先捕父类就会把「字段类型不对」当成「JSON 格式坏了」,日志里永远看不到真正的问题。修法:except msgspec.ValidationError 写在前面,except msgspec.DecodeError 兜后面。
报错五:orjson.JSONDecodeError: str is not valid UTF-8: surrogates not allowed
上游把 \xff 这类字节塞进了字符串字段。标准库会在某些路径上把它当普通字符放过,orjson 直接拒收(README 明写输入必须是合法 UTF-8)。修法是在入口处 raw.decode('utf-8', errors='replace') 或明确拒收,别等到写库时才炸。
另外两个顺手记下的:嵌套自引用会得到 ValueError: Circular reference detected(标准库),而 set 在标准库是 TypeError: Object of type set is not JSON serializable、在 orjson 是 TypeError: Type is not JSON serializable: decimal.Decimal(换成 Decimal 时)——msgspec 原生支持 Decimal,还能配 decimal_format。这三个库对「什么算可序列化」的定义,本来就不是一套。
怎么选:三条落地判据
1. 纯读、纯快、数据干净:orjson。返回 bytes、NaN 转 null、64 位上限,三条都记住就不会出事。
2. 要把 JSON 变成有类型的东西:msgspec。边解析边校验,错误带字段路径,bool 冒充 int 也能拦住;代价是必须显式 forbid_unknown_fields=True,否则多余字段静默消失。
3. 只在脚本里存个中间态、人对文件要有信心:标准库。它最宽容,也最不设防。
迁移时按这个顺序改:先扫 NaN 与 Infinity(两个新库都会改成 null),再查所有 datetime.now() 无时区调用,最后确认所有 dict 键都是 str。三件事做完,性能提升才轮得到谈。
本文的边界
我只在本机测了 CPython 3.13.14 与 Windows 11,没测 PyPy、没测 Linux;也没有覆盖 HTTP 框架的 json 集成、跨语言消费端(JavaScript 的 JSON.parse 拿到 NaN 会直接抛错,这与本文「标准库写出非法 JSON」同源,但前端那一侧我没实测)、MessagePack 与 YAML 等其他协议,以及 numpy 数组的序列化路径。这些都属于「换个库还要再验一遍」的部分。
最后
三个库没有优劣,只有默认值不同。真正会咬人的不是性能差距,而是那四处「不报错的改动」:NaN 变 null、时区消失、键类型变了、字段被丢掉。它们的共同点是——测试用例里不会出现,生产数据里一定会出现。
延伸阅读:编码层的另一条线是 Parquet 默认字典编码让文件大了 15%,讲的是磁盘格式里同样的「默认值陷阱」;Python 工程线还有一篇 标准库 logging 的丢日志实测,同样是「配置看起来没问题,数据却少了」。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):