开头先讲个真实场景。上个月我在调试一个爬虫项目,从某个数据接口拿到的返回结果是一长串带嵌套结构的文本,当时年轻,想着直接拿字符串切片去匹配数据,结果被里面层层嵌套的括号和转义字符折腾到怀疑人生。后来老老实实用 json 模块去解析,三行代码解决问题。这件事给我的教训是: 在python里处理数据交换,json模块就是那个最不该绕开的工具。
这篇文章就围绕 json 模块的核心玩法展开,重点解决三类问题:字典/列表怎么变成json字符串,json字符串怎么变回字典/列表,以及实际项目中文件读写、异常处理、自定义对象序列化这些绕不开的坑。适合刚入门python、或者已经写了一阵子但一直靠 eval 和字符串切片硬扛的读者。看完你可以直接把文中的写法抄进自己的项目里。
1. 为什么程序离不开json转换
1.1 json是程序之间的“普通话”
先理清一个概念。json全称是javascript object notation,但今天它早就超出了javascript的范畴,成了后端接口、配置文件、日志存储、数据交换的事实标准。你在网上看天气接口、查快递单号、刷微博时间线,底层返回的数据十有八九是json格式。
python里的字典和列表是内存中的对象,一个程序用完后进程结束,数据就没了。要让数据存活下来、或者传给另一个语言写的服务,必须把它变成一串 纯文本 。这个“把内存对象变成文本”的动作叫 序列化 ,反过来“把文本还原成内存对象”叫 反序列化 。 json 模块干的就是这件事。
1.2 不转换直接用字符串拼接行不行
很多人刚开始会想:我不就是拼个字符串嘛,用f-string不就行了?比如构造一个用户信息:
name = "张三"
age = 25
# 不推荐:手拼json字符串
payload = '{"name": "' + name + '", "age": ' + str(age) + '}'
这个写法一旦遇到name里有双引号、换行符,或者age变成none,字符串直接崩给你看。就算你小心翼翼转义了所有特殊字符,下一个维护你代码的人内心也是崩溃的。
用 json 模块只需要:
import json
payload = json.dumps({"name": "张三", "age": 25})
少了转义、少了类型转换、少了拼接逻辑,而且保证输出的字符串 一定是合法json 。这不是省几行代码的问题,是把一类错误直接消灭掉。
2. 字典/列表转json字符串:json.dumps的完整姿势
2.1 最基础的一行代码
dumps 方法负责把python对象变成json字符串。
“dump”加“s”的“s”代表string,记住这个规律就不会和后面要讲的 dump (写文件)搞混。
import json
data = {
"name": "张三",
"age": 25,
"tags": ["python", "json"],
"is_active": true,
"score": none
}
json_str = json.dumps(data)
print(json_str)
# {"name": "\u5f20\u4e09", "age": 25, "tags": ["python", "json"], "is_active": true, "score": null}
注意几个转换细节:
- python的
true变成小写的true,false变成false - python的
none变成小写的null - 元组会被转成json数组
- 字符串里的中文默认被转成了
\uxxxx形式的unicode转义序列
这最后一点经常把新手吓一跳,其实是 ensure_ascii 参数在起作用,下面单独说。
2.2 中文乱码问题与ensure_ascii=false
默认情况下, dumps 把所有非ascii字符都转成 \uxxxx 。这样设计是为了保证生成的json字符串在任何编码环境下都不会乱码,但可读性实在太差。
如果你这个json是给人看的——比如写配置文件、导出数据报表——加上 ensure_ascii=false :
json_str = json.dumps(data, ensure_ascii=false)
print(json_str)
# {"name": "张三", "age": 25, "tags": ["python", "json"], "is_active": true, "score": null}
这里要提醒一个细节: ensure_ascii=false 只影响输出,不影响json的合法性。无论转不转义,解析方拿到的都是同一个字符串。
真正要注意的是文件读写时的编码要配套,写文件用 encoding="utf-8" ,读文件同样用 encoding="utf-8" ,否则一边是utf-8一边是gbk,照样乱码。
2.3 indent、sort_keys、separators的参数细节
格式化输出:indent
调试时或者写配置文件,希望输出有缩进、可读性强,用 indent 参数:
json_str = json.dumps(data, ensure_ascii=false, indent=2) print(json_str)
输出效果:
{
"name": "张三",
"age": 25,
"tags": [
"python",
"json"
],
"is_active": true,
"score": null
}
indent 的单位是空格数,常用的有2和4。
indent=0 会输出换行但不缩进, indent=none (默认)输出的是最紧凑的单行格式。
固定键顺序:sort_keys
字典在python 3.7+是保持插入顺序的,但json本身不保证键的顺序。
如果你希望输出的json键按字母排序,方便对比或测试,用 sort_keys=true :
json_str = json.dumps(data, ensure_ascii=false, sort_keys=true)
# {"age": 25, "is_active": true, "name": "张三", "score": null, "tags": ["python", "json"]}
这个参数在做配置对比、生成固定签名的场景下特别有用。
前后两次生成的json字符串完全一致,方便做哈希或比对。
压缩存储:separators
反过来,如果你要把json存到数据库字段、缓存或日志里,希望体积尽可能小,用 separators 参数去掉多余空格:
json_str = json.dumps(data, ensure_ascii=false, separators=(',', ':'))
print(json_str)
# {"name":"张三","age":25,"tags":["python","json"],"is_active":true,"score":null}
separators 接收一个二元组,第一个是元素之间的分隔符,第二个是键值之间的分隔符。默认是 (', ', ': ') ,改成 (',', ':') 后每个键值对之间少一个空格,数据量大的时候压缩效果可观。
我之前处理过几万条记录导出成json,光这一项就省了大约15%的存储空间。
2.4 python类型与json类型的映射表
搞清楚类型对应关系,是避免序列化报错的关键。
这个表建议刻进脑子里:
| python类型 | json类型 | 说明 |
|---|---|---|
| dict | object | 键会被转成字符串 |
| list, tuple | array | 元组也会变成数组 |
| str | string | 默认转义非ascii字符 |
| int, float | number | 支持整型和浮点型 |
| true / false | true / false | 首字母变小写 |
| none | null | 对应json的null |
| bytes | 不支持 | 默认会抛typeerror |
| set | 不支持 | 默认会抛typeerror |
| datetime | 不支持 | 需要自定义序列化逻辑 |
bytes 、 set 、 datetime 这些类型直接 dumps 会报 typeerror: object of type xxx is not json serializable ,这是最常遇到的异常之一。解决办法后面第5章专门讲。
2.5 skipkeys参数:键不是字符串时怎么办
json规定键必须是字符串,如果python字典里的键是别的类型, dumps 默认会报 typeerror 。但有些场景下你确实有非字符串键,比如元组键:
data = {(1, 2): "a", (3, 4): "b"}
json.dumps(data) # typeerror: keys must be str, int, float, bool or none, not tuple
加上 skipkeys=true 会直接跳过这些键,而不是报错:
json.dumps(data, skipkeys=true) # {}
这个参数要慎用。跳过键等于静默丢数据,很容易留下隐患。
我通常建议:先检查数据源,把键规范成字符串,而不是用 skipkeys 掩盖问题。
3. json字符串转回字典/列表:json.loads与异常处理
3.1 基础用法
loads 和 dumps 正好相反,把json字符串转回python对象:
import json
json_str = '{"name": "张三", "age": 25, "tags": ["python", "json"], "is_active": true, "score": null}'
data = json.loads(json_str)
print(data)
# {'name': '张三', 'age': 25, 'tags': ['python', 'json'], 'is_active': true, 'score': none}
注意转换规则是逆向的:json的 true 变回python的 true , null 变回 none ,数组变回列表。
如果json字符串最外层是数组,加载回来就是列表:
json_str = '[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]'
data = json.loads(json_str)
print(data[0]["name"]) # a
3.2 jsondecodeerror异常处理
loads 最常见的失败是字符串格式不合法。
比如你从接口拿到的数据被截断了,或者手写的json少了一个大括号:
bad_str = '{"name": "张三", "age": 25,'
try:
data = json.loads(bad_str)
except json.jsondecodeerror as e:
print(f"解析失败:{e}")
print(f"出错位置:第 {e.lineno} 行,第 {e.colno} 列")
jsondecodeerror 有几个属性非常有用:
doc:原始字符串pos:出错位置在字符串中的索引lineno:出错行号colno:出错列号
实际项目里我会把解析封装成一个函数,统一处理异常并记录日志:
def safe_loads(json_str, default=none):
try:
return json.loads(json_str)
except (json.jsondecodeerror, typeerror):
return default
这样接口返回异常数据时程序不会直接崩溃,而是返回一个默认值,后续逻辑自行决定怎么处理。
3.3 为什么不要用eval代替loads
很多人刚学的时候会问:json字符串看起来就是一个python字面量,直接 eval 不就行了吗?
data = eval('{"name": "张三"}') # 能跑,但极度危险
eval 会执行任意python表达式。如果json字符串里混入了恶意代码——比如 {"name": __import__("os").system("rm -rf /")} ——你的程序就把系统命令执行了。
json.loads 只解析json语法,不执行任何代码,这是根本区别。 凡是json解析一律用json模块,不用eval,这句话值得写进团队规范。
3.4 parse_int与parse_float:解析数字时的定制钩子
loads 还支持两个不太常用但很好用的参数: parse_int 和 parse_float 。它们负责把json字符串里的数字转成python对象。
比如接口返回的数字是字符串形式的"100",你想在解析阶段自动转成 decimal避免浮点精度问题:
from decimal import decimal
data = json.loads('{"price": 19.99}', parse_float=decimal)
print(data["price"]) # 19.99,类型是decimal
这个场景在处理金额、精度敏感的数据时非常重要。
默认的 parse_float=float 会有二进制浮点误差, 0.1 + 0.2 不等于 0.3 的问题在json解析中同样存在。用 decimal 可以规避。
4. 实际项目里更常用的json.dump与json.load(文件读写)
4.1 为什么推荐dump而不是先dumps再写文件
dumps 是生成字符串, dump 是直接写入文件对象。
看名字很像,使用场景完全不同:
import json
data = {"name": "张三", "age": 25}
# 方式一:dumps + 手动写文件(不推荐)
with open("data.json", "w", encoding="utf-8") as f:
f.write(json.dumps(data, ensure_ascii=false, indent=2))
# 方式二:dump直接写文件(推荐)
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=false, indent=2)
推荐 dump 的原因很实在:你不需要自己管理字符串的写入生命周期, dump 内部处理好了。读文件时对应 load :
with open("data.json", "r", encoding="utf-8") as f:
data = json.load(f)
4.2 配置文件读写的实操模板
我常用的json配置文件读写模板长这样:
import json
from pathlib import path
config_path = path("config.json")
def load_config(path: path = config_path) -> dict:
if not path.exists():
return {}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_config(data: dict, path: path = config_path) -> none:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=false, indent=2, sort_keys=true)
几个细节说明:
path对象可以直接传给open,不用先转成字符串encoding="utf-8"必须显式指定,避免windows下默认gbk编码导致中文乱码indent=2让配置文件有可读性,sort_keys=true让键顺序固定,方便git diff对比
4.3 大json文件的读取问题
如果你的json文件很大(几gb级别),直接用 json.load 会把整个文件载入内存,很容易内存爆炸。这种情况有两个处理思路:
思路一是 流式读取 ,但标准 json 模块不支持增量解析,需要逐段处理或用 ijson 这类第三方库。
思路二是 按行存储 ,设计数据格式时每行一个json对象,读取时逐行 loads :
def load_jsonl(path):
"""逐行读取json lines格式的文件"""
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
yield json.loads(line)
# 用法
for record in load_jsonl("huge_data.jsonl"):
print(record["id"])
json lines格式在日志分析和数据管道中非常流行,每行一个独立json对象,天然支持流式处理、断点续读和并行分块。
5. 我踩过的坑和进阶处理技巧
5.1 datetime和自定义对象无法序列化
这是 json.dumps 报错频率最高的场景。模型里有 datetime 字段,一 dumps 就抛 typeerror 。
解决方案是用 default 参数指定自定义转换函数:
from datetime import datetime
def json_default(obj):
if isinstance(obj, datetime):
return obj.strftime("%y-%m-%d %h:%m:%s")
if hasattr(obj, "__dict__"):
return obj.__dict__
raise typeerror(f"object of type {type(obj)} is not json serializable")
data = {"name": "张三", "created_at": datetime.now()}
json_str = json.dumps(data, ensure_ascii=false, default=json_default)
更优雅的方式是继承 json.jsonencoder :
class customencoder(json.jsonencoder):
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)
json_str = json.dumps(data, ensure_ascii=false, cls=customencoder)
两种方式效果类似, cls 参数适合在多个地方复用同一套编码逻辑,模块化更好。
我在项目里通常维护一个 utils/json_encoders.py ,集中放所有自定义类型的转换规则,所有接口统一引用。
5.2 浮点数精度与decimal
前面提到过 parse_float 。这里再补充一个实际案例:有一次我从支付平台回调里解析金额,用默认的 float 解析,结果 0.29 被存成了 0.29000000000000004 ,对账怎么都对不上。后来改成:
from decimal import decimal
def parse_decimal(s):
return decimal(s)
data = json.loads(callback_body, parse_float=parse_decimal)
金额字段从此稳定精确。处理金额、汇率、坐标这类对精度敏感的数据时,强烈建议用 decimal 替代 float 。
5.3 非字符串键被静默转换
json的键必须是字符串,但python字典的键可以是整数。 dumps 时整数键会 自动转成字符串 ,这个过程是静默的,很多时候你没意识到数据已经变了:
data = {1: "a", 2: "b"}
json_str = json.dumps(data)
print(json_str) # {"1": "a", "2": "b"}
转回来时:
recovered = json.loads(json_str)
print(recovered) # {'1': 'a', '2': 'b'}
注意,键从 1 变成了 '1' 。如果你后续用 recovered[1] 取值,会直接keyerror。这是个非常隐蔽的坑。
解决方案是在解析后统一处理键类型,或者设计数据时就避免非字符串键。把字典的键统一规范为字符串,省掉后面一堆麻烦。
5.4 超大整数精度丢失
如果你处理的json字符串里包含超过javascript安全整数范围的数字(比如雪花算法生成的id),直接 json.loads 解析成python的 int 没问题。但如果这个json字符串是给前端js用的,js的 number 类型会精度丢失。
比如 9223372036854775807 在js里会被解析成 9223372036854776000 。解决办法是把大整数序列化为字符串:
def json_default(obj):
if isinstance(obj, int) and obj > 2**53:
return str(obj)
return super().default(obj) if hasattr(super(), 'default') else str(obj)
这个细节在和前端联调时非常关键。经验是: 所有可能超过2^53的整数,传输时一律转成字符串 。
5.5 object_hook:解析时定制对象结构
loads 支持 object_hook 参数,可以在解析json对象时对结果做二次加工。比如你想把嵌套字典自动转成某个类的实例:
class user:
def __init__(self, name, age):
self.name = name
self.age = age
def user_hook(d):
if "name" in d and "age" in d:
return user(d["name"], d["age"])
return d
data = json.loads('{"user": {"name": "张三", "age": 25}}', object_hook=user_hook)
print(data["user"].name) # 张三
object_hook 对json里 每个对象 都会调用一次,所以函数内部要判断当前对象是否包含目标字段,不匹配的原样返回。这个机制在处理嵌套配置、复杂数据模型时能省掉很多手动转换的代码。
5.6 http接口中的json参数与常见错误
用 requests 库请求接口时,很多人分不清 json 参数和 data 参数的区别。 json= 会自动帮你做序列化并设置 content-type: application/json :
import requests
payload = {"name": "张三", "age": 25}
resp = requests.post("https://api.example.com/users", json=payload)
如果手动用 data=json.dumps(payload) ,还要自己设置header:
headers = {"content-type": "application/json"}
resp = requests.post("https://api.example.com/users", data=json.dumps(payload), headers=headers)
两种方式等价,但 json= 更省心。接收响应时, resp.json() 内部其实就是调用的 json.loads(resp.text) ,不需要自己再解析一遍。
这里要特别提醒一个高频报错:
json.decoder.jsondecodeerror: expecting value: line 1 column 1 (char 0)
这个错误通常表示响应体里 根本就不是json——可能是空字符串、可能是html错误页、可能是网关返回的纯文本。
出现这个错误时,先打印 resp.text 看看实际内容,别急着怀疑json模块。
6. 三个月实操后的经验总结
最后分享几个我实际项目中沉淀下来的习惯,都是反复踩坑后总结的:
统一封装json读写工具函数。 不要在每个模块里直接散落 json.dumps 和 json.loads ,封装统一的工具函数,把 ensure_ascii=false 、 indent=2 、 encoding="utf-8" 这些参数写死在工具层,团队里所有人调同一个入口。
所有涉及金额的字段用decimal。 从接口解析到序列化返回,全程走 parse_float=decimal 和自定义 default ,把精度问题挡在入口和出口。
日志里打印json片段时压缩存储。 大json打日志会刷屏,用 separators=(',', ':') 生成紧凑格式,既保留信息又不占太多空间。
接口返回前做一次合法性校验。 用 json.dumps 序列化响应体时,如果某个字段类型不支持,会直接在接口层报500。在开发阶段我会写一个递归校验函数,提前发现不可序列化的字段。
测试用例里固定sort_keys=true。 断言接口返回时,开启 sort_keys 让json字符串有确定顺序,测试用例更稳定,不会因为字典遍历顺序不同而误报。
json模块的核心玩法就这些: dumps / loads 管字符串, dump / load 管文件, ensure_ascii 管中文, indent 管格式, sort_keys 管顺序, default 管自定义类型, object_hook 管解析加工。把这几个参数用熟,绝大多数日常场景都能覆盖。剩下那些极少碰到的边界情况,去翻官方文档时你也会发现,万变不离其宗。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论