前言
json是标准库里最"看起来简单"、实际最容易写错细节的模块之一。加载、转储,两个函数就够用——直到你遇到中文变成\uXXXX、datetime对象报TypeError、读文件时编码不对导致解析失败、同一个键在 JSON 里出现两次却只留下一个。
本文把json模块的关键参数逐个讲清,重点放在那些"默认行为不符合直觉"的地方:ensure_ascii默认是True(中文会被转义)、indent会顺带改变分隔符、default是处理不可序列化对象的正确钩子、object_pairs_hook能拿到重复键的原始对。同时点破两个 JSON 格式本身的限制:没有注释,也没有日期类型。
代码都基于 Python 3,本机没有解释器,只能逐行人工推演,不声称运行过。
一、四个入口与它们的签名
模块对外只有四个常用函数,两两对称:
# 适用于 Python 3.8+
import json
# 对象 <-> 字符串
s = json.dumps({"a": 1}) # dict -> str
obj = json.loads(s) # str/bytes/bytearray -> Python 对象
# 对象 <-> 文件(fp 需要有 write / read 方法)
with open("data.json", "w", encoding="utf-8") as f:
json.dump({"a": 1}, f)
with open("data.json", "r", encoding="utf-8") as f:
obj = json.load(f)必须记住:dumps/loads带s(string),dump/load不带s,操作的是文件对象。这对命名是最常见的笔误来源。
dumps的完整参数表(关键字专用)是:skipkeys、ensure_ascii、check_circular、allow_nan、cls、indent、separators、default、sort_keys。loads则是:cls、object_hook、parse_float、parse_int、parse_constant、object_pairs_hook。从 3.6 起所有可选参数都是关键字专用,位置传参会报错。
二、ensure_ascii:中文为什么会变样
ensure_ascii的默认值是True。这意味着:默认情况下,所有非 ASCII 字符都会被转义成\uXXXX形式。
# 适用于 Python 3.8+
import json
data = {"城市": "北京"}
print(json.dumps(data))
# 默认输出里"城市""北京"会变成 \u 转义序列
print(json.dumps(data, ensure_ascii=False))
# 传 False 才会原样保留中文字符转义本身不改变语义——json.loads能把两种形式都还原成同样的 Python 字符串。所以这不是"错误",只影响可读性和文件体积。但如果你要把 JSON 给人看,或者给需要可读文本的下游用,就该显式传ensure_ascii=False;写文件时还要保证文件是以 UTF-8 打开,否则可能因为编码不匹配而写入失败。
# 适用于 Python 3.8+
import json
with open("out.json", "w", encoding="utf-8") as f:
json.dump({"城市": "北京"}, f, ensure_ascii=False, indent=2)注意open里那个encoding="utf-8":不写它就会用平台默认编码,在 Windows 上通常不是 UTF-8,于是中文可能写坏、读回来json.load直接抛JSONDecodeError(这是json模块在解析失败时抛出的异常类型)。跨平台代码里,编码一定要显式写。
三、格式化:indent、sort_keys与separators
indent可以让输出带上换行与缩进,方便人读。它接受正整数(缩进这么多空格),也接受字符串(比如"\t")。传None(默认)则不换行。
有一个容易被忽略的联动:separators的默认值取决于indent。当indent为None时,默认分隔符是紧凑的(', ', ': ');当indent不是None时,默认分隔符变成(',', ': ')——即逗号后不再加空格,避免行尾多出空白。如果你为了减小体积手动传了separators=(",", ":"),那就完全紧凑、没有空格。
sort_keys=True会让字典按键排序后输出。默认是False,也就是保持插入顺序。官方文档明确说明:json模块的编码器与解码器默认保持输入输出顺序,只有底层容器本身无序时顺序才会丢失。
这一点和 Python 的字典有关:从 3.7 起,字典保证保持插入顺序(3.6 是实现细节,不要依赖)。所以在 3.7 及以上,dumps不排序也会原样保留键顺序。注意sort_keys是按转换后的字符串键排序,而不是按原始键类型排序——如果键是数字,排序结果可能和你预期的不一样。
| 参数 | 默认值 | 作用 | 常见误用 |
|---|
ensure_ascii | True | 是否转义非 ASCII | 以为默认输出中文 |
indent | None | 缩进,int 或 str | 以为只影响缩进,忽略它改变分隔符 |
sort_keys | False | 按键排序输出 | 在 3.7+ 上误以为必须开才能保序 |
default | None | 不可序列化对象的兜底函数 | 误当成object_hook的搭档 |
separators | 随indent变 | 控制项与项之间的分隔符 | 手写后与缩进风格冲突 |
四、default:处理datetime等不可序列化对象
JSON 的类型只有:对象、数组、字符串、数字、true/false、null。没有日期类型。这是 JSON 格式本身的限制,不是 Python 的。
所以datetime、date、Decimal、自定义类实例,json.dumps都不认识,默认会抛TypeError。官方文档给出的扩展方式有两种:
方式一:传default回调。当编码器遇到不能序列化的对象时,调用这个函数,它的返回值会被当作可序列化的对象继续处理。如果它也无法处理,应当抛出TypeError。
# 适用于 Python 3.8+
import datetime
import json
def json_default(obj):
if isinstance(obj, (datetime.datetime, datetime.date)):
return obj.isoformat() # 转成 ISO 8601 字符串
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"无法序列化的类型: {type(obj).__name__}")
payload = {"when": datetime.datetime(2026, 10, 7, 12, 0, 0)}
print(json.dumps(payload, default=json_default, ensure_ascii=False))方式二:继承json.JSONEncoder并覆写default方法。这是cls参数的用途。仅当需要复用(多处都按这套规则编码)时才值得,单点使用直接用default更省事。
反序列化时的对应问题是:isoformat()出来的字符串读回来还是字符串,不会自动变回datetime。要还原就得自己解析(比如用datetime.fromisoformat,注意它接受的格式范围随版本扩展过)。JSON 里没有类型标注,谁转成字符串的、按什么格式转的,只能靠约定。
五、object_pairs_hook与重复键
JSON 标准允许对象里出现重复的键(虽然不推荐)。Python 的json.loads默认把它们塞进dict,后出现的覆盖先出现的,前面的静默丢失。
如果你想看到"原始键值对序列",用object_pairs_hook:它接收一个由键值对组成的列表,返回你构造的对象。官方文档指出,如果同时设置了object_hook,object_pairs_hook优先。
# 适用于 Python 3.8+
import json
def no_dup_pairs(pairs):
"""检测重复键;有重复就抛错,拒绝静默覆盖。"""
seen = {}
for key, value in pairs:
if key in seen:
raise ValueError(f"JSON 中存在重复键: {key!r}")
seen[key] = value
return seen
raw = '{"a": 1, "a": 2}'
print(json.loads(raw)) # 后值覆盖前值
print(json.loads(raw, object_pairs_hook=no_dup_pairs)) # 抛错,暴露问题这个钩子的另一个用途是保序地构造有序结构——虽然 3.7 起字典已保序,这个用途的必要性下降了,但"检测重复键"的价值依然存在。对外部数据做严格校验时,重复键往往是异常信号。
六、数字:大整数与浮点精度
JSON 的数字不带类型标注。json解码时默认这样映射:带小数点或指数的解析成float,否则解析成int。
两个要留意的地方:
大整数。Python 的int是任意精度的,但出于安全考虑,从 3.11 起,默认的parse_int会对整数字符串的长度设限(利用解释器自身的整数字符串转换长度限制),以避免超长数字造成的拒绝服务攻击。如果你确实要处理超长整数,需要显式传自己的parse_int。
浮点精度。JSON 里的0.1解析成 Python 的float,而float是二进制浮点,无法精确表示十进制的 0.1。所以json.loads("0.1") + json.loads("0.2")不会等于json.loads("0.3")——这是浮点本身的实现特性,不是json的 bug。要精确的十进制运算,可以让parse_float用decimal.Decimal来构造。
# 适用于 Python 3.8+
import decimal
import json
print(json.loads("0.1") + json.loads("0.2") == json.loads("0.3")) # False
exact = json.loads("0.1", parse_float=decimal.Decimal)
print(exact, type(exact).__name__)另外,json.dumps默认allow_nan=True,会把float('nan')、float('inf')写成NaN、Infinity。这些不是合法的 JSON。如果输出要交给严格的其他语言解析器,应传allow_nan=False,让它在遇到这些值时直接报错。
七、JSON 的两个天生缺失:注释与类型
JSON 没有注释。官方规范里根本不支持注释。所以{"a": 1} // 说明这种写法解析会失败。需要配置里带注释,应当换成别的格式(比如tomllib能读的 TOML,从 3.11 起进入标准库),而不是自己用正则去"删注释再解析"——那种做法会把字符串里的//也误删。
JSON 没有日期、没有二进制、没有自定义类型。这些都必须先转成字符串或经过default转换。转换规则由你和你的下游共同约定,JSON 不提供任何元信息。
常见坑点
- 以为
dumps默认输出中文。
❌json.dumps({"城市": "北京"})后抱怨中文全变成了\u转义。 ✅ensure_ascii默认为True;要保留原文需传ensure_ascii=False,并保证文件以 UTF-8 打开。
open不写encoding。
❌ 读写 JSON 文件时不指定编码,在 Windows 上读写中文出错或解析失败。 ✅ 读写都显式写encoding="utf-8";json.load遇到非法文档会抛JSONDecodeError。
- 把
default当成反序列化的钩子。
❌ 期待用default在读取时把字符串还原成datetime。 ✅default只在编码阶段处理不可序列化对象;反序列化的还原要靠object_hook或自己解析。
- 给 JSON 加注释。
❌ 在.json文件里写//或#注释,再让程序解析。 ✅ JSON 格式不支持注释;配置类数据改用支持注释的格式,不要用正则剥注释再解析。
- 以为
indent只影响缩进。
❌ 设了indent=2后发现行尾多了空格或没有空格,与自己手写的separators冲突。 ✅ 记住indent不为None时默认分隔符会变成无空格的紧凑形式;需要精确控制就显式传separators。
- 在 3.7+ 上仍用
sort_keys来"保证顺序"。
❌ 认为不排序键顺序就会乱。 ✅ 3.7 起字典保证插入顺序,json也保持顺序;sort_keys的用途是让输出便于比对,而非保序。
- 忽略重复键被静默覆盖。
❌ 对外部数据直接json.loads,同一键出现两次时前面那份悄悄丢了。 ✅ 用object_pairs_hook检测重复键并报错;注意它优先于object_hook。
- 把
NaN/Infinity当成合法 JSON。
❌ 默认序列化结果里带NaN,再交给别的语言的解析器读取时失败。 ✅ 这类值不是合法 JSON;需要严格输出时传allow_nan=False提前暴露问题。
总结
| 需求 | 做法 | 关键参数 |
|---|
| 中文可读输出 | 关掉 ASCII 转义 | ensure_ascii=False |
| 格式化输出 | 缩进 + 可选排序 | indent、sort_keys、separators |
| 序列化日期/自定义对象 | 编码阶段的兜底 | default=或覆写JSONEncoder |
| 检测重复键 | 拿到原始键值对 | object_pairs_hook= |
| 高精度十进制 | 替换浮点解析 | parse_float=decimal.Decimal |
| 严格 IO 校验 | 拒绝非法字面量 | allow_nan=False |
json模块的"简单"是一种错觉:函数只有四个,参数只有十来个,但每一个默认值背后都有取舍。记住三句话就够了——中文默认会被转义、日期必须自己转换、JSON 里没有注释也没有日期类型。剩下的细节,请以json模块官方文档的当前版本为准。