说实话,在Python里写JSON处理,很多人第一反应就是json.loads和json.dumps两个函数,背下来就完事了。但真到了生产环境里,你会发现事情远没有那么简单:好好的日期字段突然报not JSON serializable、中文变成\uXXXX一眼看不出内容、拿到别人接口返回的文件怎么也解析不了……这些坑我基本都踩过一遍,而且都不是那种特别冷门刁钻的场景,就是日常开发里高频出现的"常规麻烦"。
所以我决定把Python标准库json模块的实战经验完整梳理一遍,从一个"会用json.loads"的入门状态,讲到能应对嵌套对象、自定义类型、大文件流式解析的进阶状态。这篇内容不是帮你背API,而是告诉你每个参数在什么场景下该用、为什么这样用,以及那些官方文档没明说但实际开发中特别重要的小细节。适合任何正在用Python做接口对接、配置文件处理、数据落盘的开发者,无论你是刚接触JSON还是已经写了两三年Python,都应该能从中找到一些之前忽略掉的东西。
1. JSON在Python项目里的定位:先弄明白它在真实项目中扮演的角色
1.1 JSON和dict的本质区别:一个在内存,一个在线上
很多人会把JSON和Python里的dict混为一谈,因为打印出来长得确实很像。但这两者本质上是完全不同的东西:dict是Python进程内存里的数据结构,只能被当前Python代码直接操作;JSON是一种文本格式,它是跨语言、跨平台的数据交换中间载体。
我习惯用一个类比来理解:dict是你仓库里的货架,货物都摆在上面,你随手就能拿到。JSON则是标准化的集装箱,不管你仓库内部怎么摆放,运出去之前都要按照统一规格打包进集装箱,对方收到之后再按规格拆箱摆回自己的仓库。这个"打包"和"拆箱"的过程,就是序列化和反序列化。
想清楚这一点,很多困惑就会解开。比如你没法直接把一个Python对象塞给另一个服务——对方可能是Java写的,也可能压根不是编程语言,而是一个配置文件读取器。你必须先把对象变成JSON文本,通过HTTP请求、消息队列、文件系统传过去,对方才能用自己语言里的JSON解析库把它还原成自己的数据结构。
1.2 Python标准库json模块到底解决了什么问题
标准库里的json模块,解决的核心问题只有一个:在Python对象和JSON文本之间做无损转换。无损指的是信息能完整还原——你存进去的{"name": "张三", "age": 30},转一圈回来之后还是同样的键值对。
但在实际项目里,这个"无损"是有边界的。JSON格式本身只定义了六种数据类型:对象、数组、字符串、数字、布尔值、null。而Python世界里远不止这几种,还有元组、集合、日期时间、Decimal、bytes、枚举、自定义类实例等等。这些类型在转JSON的时候就不是"无损"的,需要额外处理。
我经常看到的一个误区是:以为只要能用json.dumps跑通的场景就没问题,等到数据里混进一个date对象、一个set就当场炸掉。所以这篇内容的重点之一,就是把"哪些能直接转、哪些必须手工处理"这件事彻底讲明白,让你的代码在真实数据面前而不是理想数据面前站得住脚。
2. 核心四件套深度拆解:dumps、loads、dump、load各自的职责边界
2.1 内存转换与文件转换:两个容易搞混的操作方向
json模块最常用的四个函数,看起来只是多个s的区别,实际分成两组:
| 函数 | 方向 | 输入 | 输出 | 典型场景 |
|---|---|---|---|---|
json.dumps | Python对象 → JSON字符串 | dict / list 等 | str | 接口返回值、消息推送、拼JSON字符串 |
json.loads | JSON字符串 → Python对象 | str / bytes | dict / list 等 | 解析响应体、读取配置文本 |
json.dump | Python对象 → JSON文件 | dict / list 等 + 文件对象 | 无(写入文件) | 配置落盘、缓存写入 |
json.load | JSON文件 → Python对象 | 文件对象 | dict / list 等 | 读取配置文件、加载导出数据 |
dumps和loads处理的是内存里的字符串,dump和load处理的是文件对象。后两个会在内部帮你完成"读文件→解析字符串"或"序列化字符串→写文件"的全过程,所以很多人误以为dump就是dumps加了个文件参数,其实从设计意图上它就是专门为文件场景服务的,处理编码的方式也有一些必须注意的差别,后面我会专门讲。
2.2 dumps的关键参数:ensure_ascii、indent、sort_keys的真实用途
很多人在初期只用json.dumps(data),输出的内容又长又没有缩进,中文还全部变成\uXXXX。这其实不是Python转错了,而是默认参数在设计上优先考虑了"通用安全"而不是"人眼可读"。
第一个必提参数是ensure_ascii。默认值是True,意思是在序列化时把所有非ASCII字符都转成\uXXXX形式。这样做的设计初衷是保证输出的JSON在任何编码环境里都不会乱码,但在实际开发中,如果你需要把结果直接展示给用户、写入日志、或者给前端渲染,\u4f60\u597d这种内容是没法看的。所以我基本所有项目里都会写上ensure_ascii=False,让中文原样输出:
import json data = {"name": "张三", "message": "你好,世界"} print(json.dumps(data, ensure_ascii=False)) # {"name": "张三", "message": "你好,世界"}第二个参数indent,控制缩进和换行。默认是None,输出一行压得死死的。设置indent=2或者indent=4之后,输出会按层级缩进,如果这个JSON是要给人读的,比如配置文件模板、调试输出,这个参数能救命:
print(json.dumps(data, ensure_ascii=False, indent=2)) # { # "name": "张三", # "message": "你好,世界" # }第三个参数sort_keys,默认False,设为True后会按字典键的字典序排序输出。这个参数最容易被忽略,但它在两个场景里非常好用:一是让生成的配置文件内容顺序固定,方便diff对比;二是当你需要稳定地生成签名哈希时,键顺序稳定意味着你不需要额外做一遍排序。
2.3 压缩输出的技巧:separators参数与流量优化
和indent正好相反的场景是:数据要放到消息队列里、写入日志文件、或者通过HTTP传输,这时候你巴不得越短越好。separators参数就是干这个的。它接收一个二元组,分别指定键值对之间的分隔符和键与值之间的分隔符:
compact_json = json.dumps(data, ensure_ascii=False, separators=(",", ":")) print(compact_json) # {"name":"张三","message":"你好,世界"}注意默认分隔符是(", ", ": "),多了一个空格。很多时候大家只关注ensure_ascii和indent,忘了separators也能省带宽。我自己做过一个日志推送场景,每天上千万条数据,每行压缩掉几个字节都是可观的成本,所以那套逻辑里separators=(",", ":")是必须写上的。
还有一个skipkeys参数,默认False。当你的字典键里有非基本类型(比如元组里的元素是自定义对象),默认会直接抛出TypeError。设成True则会跳过那些无法序列化的键,而不是让整个任务挂掉。但实际使用要谨慎,因为它会把错误悄悄吞掉,可能掩盖数据本身的问题,我一般只在明确知道"丢几个键无所谓"的场景用它。
3. Python类型与JSON类型的真实映射:别等到运行时报错才想起这件事
3.1 类型对照表:熟悉这些才能真正理解"无损"的含义
官方文档给出的映射关系并不复杂,但我见过的很多初学者都在这里栽过跟头。完整的转换关系如下:
| Python类型 | JSON类型 | 转换后示例 | 反向还原 |
|---|---|---|---|
dict | object | {"a": 1} | 还原为dict |
list/tuple | array | [1, 2, 3] | 还原为list |
str | string | "hello" | 还原为str |
int/float | number | 42/3.14 | 还原为int/float |
True/False | boolean | true/false | 还原为True/False |
None | null | null | 还原为None |
一个非常容易忽略的点是:tuple会被序列化成JSON数组,但反序列化回来时只会得到list。也就是说,如果你把一个含有元组的嵌套结构dumps出去再loads回来,元组类型就永久丢失了。这不是bug,是JSON格式本身没有"元组"概念,所以不要指望往返之后类型完全一致。
另一个细节:json.dumps会把字典里整数类型的key自动转成字符串。比如{1: "a"}会变成{"1": "a"},数字1和字符串"1"在Python dict里是两个完全不同的key,但转成JSON后它们会冲突合并,最后只剩一个。这种隐性数据丢失最麻烦,因为它不报错,只是悄悄改变你的数据。处理的办法是在dumps之前就统一键的类型,别放混合类型的键进去。
3.2 高频翻车现场:set、datetime、bytes、Decimal为什么不能直接序列化
只要数据里混进JSON规范里没有的类型,json.dumps就会抛出TypeError: Object of type X is not JSON serializable。这个报错基本是Python开发者最常见的JSON相关报错之一,原因也很简单:JSON格式规定里只有那六种类型,别的都没位置放。
我列几个自己踩过和看别人踩过的高频场景:
import json from datetime import datetime, date from decimal import Decimal # set:集合是无序且不重复的数据结构,JSON没有对应概念 try: json.dumps({1, 2, 3}) except TypeError as e: print(e) # Object of type set is not JSON serializable # datetime:JSON里没有日期时间类型 try: json.dumps(datetime.now()) except TypeError as e: print(e) # Object of type datetime is not JSON serializable # Decimal:高精度十进制数在JSON里没有类型 try: json.dumps(Decimal("3.14")) except TypeError as e: print(e) # Object of type Decimal is not JSON serializable # bytes:字节串需要考虑先解码 try: json.dumps(b"hello") except TypeError as e: print(e) # Object of type bytes is not JSON serializable解决这些问题的核心思路是一致的:在序列化之前,先把"孤儿类型"转换成JSON认识的类型。set可以转list、datetime可以转ISO格式字符串、Decimal可以转float或字符串、bytes可以decode成str。转换策略取决于你的业务需求,有的场景下用字符串保留精度更好,有的场景下用float就够了。
3.3 NaN、Infinity与浮点精度:看着没问题,实际有隐患的三个数字
有一个反直觉的事实:JSON标准里其实没有显式定义NaN和Infinity的表示方式,但Python的json模块依然会照常输出它们。你可以试试:
import json print(json.dumps({"score": float("nan"), "rate": float("inf")})) # {"score": NaN, "rate": Infinity}这段代码不会报错,但输出的内容不是标准JSON。很多严格校验JSON格式的工具会直接拒绝这条数据,后端用Java、Go、JavaScript解析时也会出现各种诡异行为。所以如果数据可能来自计算过程,我建议在dumps之前先做一次数值清洗,把NaN和Infinity替换成null或者抛异常。
浮点精度是另一个隐蔽问题。0.1 + 0.2在Python里是0.30000000000000004,json模块会忠实地把这个完整的二进制浮点数表示序列化出来,如果你预期的是0.3,反序列化回来之后对比就会出现偏差。如果数据涉及金额、精度要求高,不要用float,而是用Decimal配合字符串转换来保持精度。
4. 自定义对象的序列化方案:让"be JSON不认识的对象"按你的规则进JSON
4.1 default参数与JSONEncoder子类:两种思路、一个核心
处理自定义对象序列化,官方给的最直接方案是json.dumps的default参数。这个参数接收一个函数,当默认序列化器遇到不认识的对象时,会把这个对象传给你的函数,让你决定它应该被转成什么。
比如最常见的日期时间处理:
import json from datetime import datetime def default_serializer(obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, set): return list(obj) raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") data = { "event": "user_login", "time": datetime.now(), "tags": {"admin", "vip"}, } print(json.dumps(data, default=default_serializer, ensure_ascii=False))这种写法的好处是灵活,你可以为不同类型的对象分别定制转换规则,逻辑都集中在一个函数里。但缺点是每个调用点都得手动传入default=default_serializer,一旦项目里到处都要做这种转换,漏传一两个地方就会再次炸锅。
所以我更推荐另一种更长期稳定的做法:继承json.JSONEncoder,把转换规则固化到Encoder子类里:
import json from datetime import datetime from decimal import Decimal class CustomEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, Decimal): return str(obj) if isinstance(obj, set): return list(obj) return super().default(obj) data = { "amount": Decimal("199.99"), "created_at": datetime.now(), "permissions": {"read", "write"}, } result = json.dumps(data, cls=CustomEncoder, ensure_ascii=False) print(result)这个方案在项目里的价值是:你可以在所有出口统一使用同一个Encoder,规则只维护一份,后续要改全局序列化策略的时候,只改一个类就够了。
4.2 反向操作:object_hook与object_pairs_hook如何让数据"变回"对象
序列化能写自定义规则,反序列化同样能。json.loads支持object_hook参数,它会在解析过程中遇到每个JSON对象(也就是dict)时调用一次你提供的函数,让你有机会把普通dict转换为更高级的对象。
一个比较经典的完整闭环是:序列化时把datetime转成ISO字符串,反序列化时再把字符串还原成datetime:
import json from datetime import datetime def from_json(dct): if isinstance(dct, dict): for key, value in dct.items(): if key in {"time", "created_at"} and isinstance(value, str): try: dct[key] = datetime.fromisoformat(value) except ValueError: pass return dct payload = '{"event": "user_login", "time": "2025-02-14T10:30:00"}' data = json.loads(payload, object_hook=from_json) print(type(data["time"])) # <class 'datetime.datetime'>object_pairs_hook和object_hook的区别在于:它传给回调函数的不是dict,而是一个键值对列表。这在需要保留重复键、或者需要控制字典顺序的场景下很有用。我自己主要用它来解决一个脏数据问题:当JSON里出现大量重复键名时,默认的dict解析会静默覆盖,而object_pairs_hook可以让你在构造dict之前察觉到这个冲突。
核心设计原则是:自定义对象的序列化和反序列化规则一定要成对设计。你用什么规则把对象变成JSON,就要用对应的规则把它变回来,否则数据出去之后自己都不认识自己的格式了。
5. 文件读写与错误处理:真实项目中90%的"诡异问题"都出在这里
5.1 dump和load的文件编码问题:为什么必须显式指定encoding="utf-8"
先给结论:每次打开JSON文件时,都建议显式指定encoding="utf-8",不要依赖系统默认编码。这个问题的成因很朴素——不同操作系统默认编码不一样,同一份代码在你的电脑上跑得好好的,部署到别的环境里就可能因为默认编码不同而乱码。
json.dump和json.load本身不负责编码判断,它们接收的文件对象怎么打开的,就怎么读写。最常见的错误写法是这样一个组合:
import json with open("config.json", "w") as f: json.dump({"name": "张三"}, f, ensure_ascii=False)这段代码在Windows中文环境下可能看似没问题,因为系统默认编码恰好是GBK一类的,但换到Linux上就会报错或者写出乱码。正确写法是这样:
with open("config.json", "w", encoding="utf-8") as f: json.dump({"name": "张三"}, f, ensure_ascii=False)读取时同理,用open("config.json", "r", encoding="utf-8")。还有一个更隐蔽的问题:有些工具保存JSON文件时会带BOM头(\ufeff),导致json.load直接抛JSONDecodeError。碰到这种情况,读取时指定encoding="utf-8-sig"可以自动去掉BOM,这也是我在处理第三方工具生成的配置文件时常用的一个小技巧。
5.2 解析失败时的定位思路:JSONDecodeError的doc、lineno、colno
到了真实项目中,json.loads见报错一点也不稀奇,没人能保证外部接口返回的内容一定就是合法JSON。但很多人在这个报错面前只会干瞪眼,看完Expecting value就不知道下一步干嘛。
其实json.JSONDecodeError对象里带着完整的三件套信息:doc是原始文本、lineno是出错行号、colno是出错列号。处理解析异常的正确姿势是把它充分利用起来:
import json raw_text = '{"username": "alice", "age": }' try: data = json.loads(raw_text) except json.JSONDecodeError as e: print(f"出错位置: 第{e.lineno}行, 第{e.colno}列") print(f"错误内容片段: {e.doc[max(0, e.pos - 20):e.pos + 20]}") print(f"错误原因: {e.msg}")很多时候你只需要把doc里出错位置前后的内容截取出来,就能一眼看出问题:是不是多了一个逗号、少了一个引号、还是整个响应体压根不是JSON而是一条HTML错误页。在实际对接第三方接口时,经常会遇到对方返回的不是JSON而是{"code":200}前面带着一段BOM或者空格,这时候合法做法是先用strip()清理再解析,而不是直接对原始内容json.loads。
5.3 循环引用的"炸法":RecursionError与TypeError的两个阶段
Python允许对象引用自身,比如a = [1, 2]然后a.append(a)。这种结构在内存里合法,但它无法被表示成有限深度的JSON文本。当你对这样的对象执行json.dumps时,不同情况会给出不同报错:
- 对于比较浅的循环引用,通常会抛
ValueError: Circular reference detected - 对于递归层数特别深的正常嵌套结构,可能触发
RecursionError: maximum recursion depth exceeded
两种情况的应对思路不同。循环引用属于数据本身的逻辑错误,说明你在序列化之前就应该把数据结构里的环解开,否则无论怎么调参数都没用。递归过深则是Python调用栈深度的限制,如果数据确实就是几十层甚至上百层的嵌套,你可以用sys.setrecursionlimit适当调高递归上限,但更好的方案是考虑用迭代式的自定义序列化逻辑替代递归遍历。
我在实际项目中是这样处理的:先把可能含环的对象做一次深拷贝并替换掉引用,同时维护一个已经访问过的对象id集合,遇到重复就截断。这样即便数据里意外带环,也不至于让整个任务崩溃。
6. 大文件与高性能场景:别让JSON处理成为数据链路的瓶颈
6.1 处理超大JSON不是只有"一次性loads进来"一条路
json.load默认会把整个文件读入内存并一次性构建出完整的Python对象树。文件几MB的时候没问题,但当文件达到几百MB甚至几个GB时,内存占用和解析耗时都会变得很难看。这时候真正需要的是一个更克制的处理方案。
针对"文件里一行一个JSON对象"的NDJSON格式,我用得最多的方案是用JSONDecoder.raw_decode实现流式迭代读取:
import json def iter_ndjson(file_path): decoder = json.JSONDecoder() with open(file_path, "r", encoding="utf-8") as f: buffer = "" for line in f: buffer += line.strip() if not buffer: continue try: obj, idx = decoder.raw_decode(buffer) buffer = buffer[idx:].strip() yield obj except json.JSONDecodeError: # 本行可能不完整,继续累积下一行再试 continue for item in iter_ndjson("events.ndjson"): # 每条数据独立处理,内存占用保持在低水位 process(item)使用raw_decode的巧妙之处在于,它返回两个值:解析出的对象,以及解析结束位置的索引。这样你可以从字符串中间开始解析,用完一段就丢掉一段,内存不会随着处理数据条数增加而膨胀。
如果是单个巨大的JSON数组,比如一个几百万元素的list,另一个方案是用ijson这类流式解析库按路径逐步取值。不过标准库json是不支持流式读取数组元素的,需要自己拼字符串,体验很一般。如果业务里有大量这类需求,引入ijson或orjson这类第三方库是更务实的选择。
6.2 要不要换第三方库:orjson和ujson的适用边界
社区里经常看到有人推荐用orjson、ujson替换标准库的json,说性能能提升好几倍。这个说法本身是对的,但不能盲目追。我先给一张自己在项目里的对比结论表:
| 维度 | 标准库json | orjson | ujson |
|---|---|---|---|
| 序列化速度 | 基准 | 通常快2-5倍 | 通常快1-3倍 |
| 反序列化速度 | 基准 | 通常快1-3倍 | 通常快1-2倍 |
| 支持自定义default函数 | 支持 | 支持,但写法略有差异 | 有限 |
| 默认中文处理 | 转义 | 原样输出UTF-8字节 | 需手动设置 |
| 对datetime的处理 | 不直接支持 | 内置支持 | 不直接支持 |
一个非常实际的结论是:如果你的项目已经在用标准库的json,且没有出现性能瓶颈,那就继续用,别为了快而换。因为标准库跨平台、代码兼容性、文档生态都最成熟,换第三方库很可能引入序列化行为差异,导致同样的数据在不同环境里输出不一致。
只有在两种情况下我建议主动换:一是日志分析、数据同步这类单日处理量很大的批处理任务,节省的时间是实打实的;二是接口服务和消息推送这类对响应延迟敏感的场景。换库的成本不高,但迁移前一定要跑一轮完整的单元测试,确保新旧库对特殊类型、编码、错误处理的行为一致,否则上线后等着你的就是一堆难以排查的诡异问题。
7. 一套可以抄作业的生产级配置:把前面所有要点串成一份代码
import json from datetime import datetime from decimal import Decimal from pathlib import Path class ProductionJSONEncoder(json.JSONEncoder): """生产环境JSON编码器:统一处理常见业务类型""" def default(self, obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, Decimal): return str(obj) if isinstance(obj, set): return list(obj) if isinstance(obj, bytes): return obj.decode("utf-8", errors="replace") return super().default(obj) def dumps_for_humans(data): """用于配置文件、调试输出的可读版本""" return json.dumps(data, cls=ProductionJSONEncoder, ensure_ascii=False, indent=2, sort_keys=True) def dumps_for_machine(data): """用于日志、消息队列、接口响应的压缩版本""" return json.dumps(data, cls=ProductionJSONEncoder, ensure_ascii=False, separators=(",", ":")) def load_json_file(file_path: Path): with open(file_path, "r", encoding="utf-8-sig") as f: return json.load(f) def dump_json_file(file_path: Path, data, indent: int = 2): with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, cls=ProductionJSONEncoder, ensure_ascii=False, indent=indent)这样一个封装,基本能覆盖日常开发里90%以上的JSON处理需求:面向人类阅读时用indent=2和sort_keys=True,面向机器消费时用separators压缩体积,自定义类型统一在Encoder里兜底,文件读写强制UTF-8并兼容BOM。
接口对接时的响应解析,我也建议统一封装一层:
def parse_response(raw_text: str): text = raw_text.strip() if not text: raise ValueError("响应内容为空") try: return json.loads(text) except json.JSONDecodeError as e: raise ValueError( f"JSON解析失败: {e.msg}, 位置第{e.lineno}行第{e.colno}列, " f"附近内容: {text[max(0, e.pos-20):e.pos+20]!r}" ) from e这套写法让我少了很多"莫名其妙解析失败"的排查时间,因为报错信息里直接带了位置和上下文,拿到手就能定位是对方接口返回了HTML还是数据格式有问题。
在实际开发和运维中,我还发现一个值得坚持的好习惯:给所有对外输出的JSON统一加一个版本字段。比如数据里带上"schema_version": 1,后续字段改名、类型调整时,老数据和老代码都能根据版本号做兼容处理,而不是一升级就把旧数据全部搞废。JSON本身是一种非常宽容的格式,但也正因为宽容,数据质量问题往往比代码逻辑问题更隐蔽、更难排查,所以越早养成明确的序列化规范,项目跑得越久你越省心。