本节目标:掌握 JSON、CSV、TOML 三种格式的标准库用法与各自陷阱,并建立「反序列化不可信数据等于执行代码」的安全直觉。
适用版本:Python 3.12+(实测 3.14.6)
10.3 JSON / CSV / TOML 与序列化安全
上一节 10.2 文件 I/O、pathlib 与编码 讲的是「怎么把文本落盘」。可落盘的内容大多不是散文,而是结构化的数据——配置、接口响应、表格导出。这一节逐个拆解 JSON、CSV、TOML 三种格式,最后重点讲一件容易被忽略的事:反序列化本身就可能是攻击面。
10.3.1 json.dumps / loads 的四个参数
JSON 是跨语言的数据交换标准,json.dumps 把 Python 对象转成字符串,json.loads 反向解析:
import json
data = {"name": "张伟", "age": 30, "tags": ["Python", "Go"], "active": True}
print(json.dumps(data))
print(json.dumps(data, ensure_ascii=False))
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
{"name": "\u5f20\u4f1f", "age": 30, "tags": ["Python", "Go"], "active": true}
{"name": "张伟", "age": 30, "tags": ["Python", "Go"], "active": true}
{"active": true, "age": 30, "name": "张伟", "tags": ["Python", "Go"]}
四个最常用的参数:
| 参数 | 作用 | 建议 |
|---|---|---|
ensure_ascii | 非 ASCII 是否转义 | 默认 True,中文场景设 False |
indent | 缩进美化 | 调试/配置文件设 2 |
sort_keys | 键排序 | 需要 diff 稳定时设 True |
separators | 分隔符 | 紧凑传输用 (",", ":") |
ensure_ascii=True 是默认值,所以中文会被写成 \u5f20\u4f1f 这样的转义序列。这在语义上完全合法(json.loads 能正确还原),但人眼不可读、体积也大,写文件给中文用户看时几乎总该设 ensure_ascii=False。
10.3.2 JSON 处理中文的坑
坑不在 dumps,而在「转义了但看起来不对」。json.loads 对 \uXXXX 转义是透明处理的:
raw = '{"name": "\\u5f20\\u4f1f", "age": 30}'
print(json.loads(raw))
{'name': '张伟', 'age': 30}
真正要小心的是双重编码:如果一份数据被 json.dumps 了两次,字段会变成转义后的字符串。另一个高频坑是文件写入时的编码:json.dump 默认 ensure_ascii=True 时输出纯 ASCII,用任何编码写都不会乱;一旦设了 ensure_ascii=False,就必须确保以 UTF-8 写文件,否则中文会乱码。标准组合是:
from pathlib import Path
Path("user.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
10.3.3 default 处理不可序列化对象
JSON 只认 dict、list、str、int、float、bool、None 七种类型。遇到 datetime、Decimal、自定义对象就会抛 TypeError。用 default 参数给出「兜底转换」函数:
from datetime import date
from decimal import Decimal
class Order:
def __init__(self):
self.id = "A-1"
self.created = date(2026, 9, 10)
self.price = Decimal("99.90")
def enc(o):
if isinstance(o, date):
return o.isoformat()
if isinstance(o, Decimal):
return str(o)
raise TypeError(f"不可序列化: {type(o).__name__}")
print(json.dumps(Order().__dict__, ensure_ascii=False, default=enc))
{"id": "A-1", "created": "2026-09-10", "price": "99.90"}
Decimal 被转成字符串 "99.90" 而不是 float,这是有意为之——金额转 float 会引入精度误差(3.1 节讲过),转字符串才能无损往返。
还要注意 JSON 的类型回不来:tuple 序列化后变成 list,set 根本无法直接序列化,dict 的键只能是字符串。往返一圈后 {"t": (4, 5)} 会变成 {"t": [4, 5]}。
10.3.4 csv 模块:DictReader / DictWriter
CSV 是表格数据的通用格式,但绝不要用 split(",") 手工解析——带引号的字段、字段内换行、转义引号都会出错。标准库 csv 模块处理了所有这些边界:
import csv
from pathlib import Path
csv_path = Path("/tmp/python_book/scratch/ch10/users.csv")
rows = [
{"name": "张伟", "age": 30, "city": "北京"},
{"name": "Li, Lei", "age": 25, "city": "上海"},
]
with open(csv_path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["name", "age", "city"])
writer.writeheader()
writer.writerows(rows)
print(csv_path.read_text(encoding="utf-8"))
name,age,city
张伟,30,北京
"Li, Lei",25,上海
注意 "Li, Lei" 被自动加上了引号——这正是手工 split(",") 会切错的地方。读回来用 DictReader,每行是一个以表头为键的 dict:
with open(csv_path, "r", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
print(row)
{'name': '张伟', 'age': '30', 'city': '北京'}
{'name': 'Li, Lei', 'age': '25', 'city': '上海'}
关键细节:CSV 读回来的值全是字符串,'30' 不是 30。类型转换必须自己做,这也正是 9.3 节 Pydantic 出场的地方——把 DictReader 的每行喂给 model_validate,字符串到类型的转换和校验一次搞定。
10.3.5 newline="" 为什么必要
写 CSV 时那个 newline="" 参数看着多余,其实不可或缺。原因是 csv 模块自己负责写 \r\n 行结束符,而 open 在文本模式下默认还会把 \n 翻译成平台行结束符。两者叠加,Windows 上就会写出 \r\r\n,多出一堆空行。传 newline="" 关闭 open 的换行翻译,把控制权完全交给 csv:
import csv, io
buf = io.StringIO()
csv.writer(buf).writerow(["a", "b"])
print(repr(buf.getvalue()))
'a,b\r\n'
csv 写出的行结束符是 \r\n(RFC 4180 的规定),不是 \n。读的时候同样要传 newline="",否则字段内换行可能被错误处理。这是 csv 模块官方文档反复强调的一条。
10.3.6 csv.Sniffer
拿到来源不明的 CSV,不知道分隔符是逗号、分号还是制表符?csv.Sniffer 能嗅探出方言:
sample = "name;age;city\n张伟;30;北京\n"
dialect = csv.Sniffer().sniff(sample)
print("delimiter:", repr(dialect.delimiter))
print("has_header:", csv.Sniffer().has_header(sample))
print(list(csv.DictReader(io.StringIO(sample), dialect=dialect)))
delimiter: ';'
has_header: True
[{'name': '张伟', 'age': '30', 'city': '北京'}]
sniff 给出分隔符、引号字符等方言信息,has_header 判断首行是不是表头。注意它只是启发式猜测:样本太小或数据不规则时可能猜错,生产代码应当允许用户显式指定,只在「猜」的时候用 Sniffer。
10.3.7 tomllib:只读的 TOML
TOML 是 Python 项目配置的官方格式(pyproject.toml 就是它)。标准库 tomllib 从 3.11 起提供,但只能读、不能写:
import tomllib
toml_text = """
[project]
name = "demo"
version = "0.1.0"
requires-python = ">=3.12"
[dependencies]
requests = "^2.32"
"""
cfg = tomllib.loads(toml_text)
print(cfg["project"]["name"], cfg["project"]["requires-python"])
print(cfg["dependencies"])
demo >=3.12
{'requests': '^2.32'}
关键限制:tomllib.load 只接受二进制文件对象(open(path, "rb")),因为它要先按 UTF-8 解码并校验编码。写 TOML 标准库没有方案,需要第三方库 tomli-w(本环境未预装,故此处未实际运行):
# 需先 pip install tomli-w;本环境未安装,未运行
import tomli_w
tomli_w.dumps({"project": {"name": "demo", "version": "0.1.0"}})
日常项目里,配置读用 tomllib 就够了;写 pyproject.toml 通常交给构建工具,不需要程序自己生成。
10.3.8 序列化安全:pickle 的危险
pickle 能把任意 Python 对象序列化成字节,包括自定义类、函数、甚至闭包。它的危险也正来自「任意」:反序列化时会执行字节码来重建对象,攻击者可以在 payload 里埋下任意代码。下面这段真实运行过——注意 loads 那一刻命令就被执行了:
import pickle, os, sys
class Evil:
def __reduce__(self):
return (os.system, ("echo '[!] 反序列化时执行了任意命令'",))
payload = pickle.dumps(Evil())
print("payload len:", len(payload))
print("--- 执行 loads ---")
sys.stdout.flush()
pickle.loads(payload) # 这一行执行了 echo 命令
print("--- 结束 ---")
payload len: 85
--- 执行 loads ---
[!] 反序列化时执行了任意命令
--- 结束 ---
__reduce__ 告诉 pickle「重建这个对象时要调用 os.system(...)」。真实的攻击载荷会在这里放反弹 shell、下载木马、读密钥。结论只有一条:永远不要 pickle.loads 来自不可信来源的数据——网络、用户上传、第三方缓存都不行。pickle 只用于「自己写、自己读」的临时缓存或模型文件。
10.3.9 YAML safe_load 与 JSON 的边界
YAML 的表达能力比 JSON 强(支持锚点、类型标签),这同样是攻击面。yaml.load 能构造任意 Python 对象,yaml.safe_load 只允许基本类型:
# 本环境未安装 pyyaml,以下为官方文档行为说明,未实际运行
import yaml
cfg = yaml.safe_load(text) # ✅ 安全:只解析 dict/list/标量
cfg = yaml.load(text) # ❌ 危险:可构造任意对象
规则和 pickle 一致:解析不可信 YAML 一律用 safe_load,永远不用裸 load。 若确实需要更强的类型控制,用 yaml.safe_load 配合显式 schema。
那 JSON 是不是就绝对安全?不是。JSON 不执行代码,但有资源耗尽类问题。本机实测两个边界:
import json
try:
json.loads("[" * 200000 + "]" * 200000)
except RecursionError as e:
print("deep:", str(e)[:40])
try:
json.loads("1" * 5000)
except ValueError as e:
print("big:", str(e)[:40])
deep: Stack overflow (used 16352 kB) while dec
big: Exceeds the limit (4300 digits) for inte
深度嵌套会触发递归栈溢出,超大整数会撞上 CPython 4300 位的字符串转换上限(3.1 节讲过)。对不可信的 JSON 输入,应当限制请求体大小、限制嵌套深度,必要时在独立进程里解析并设超时。JSON 的安全问题不是「执行代码」,而是「耗尽资源」。
序列化格式的完整选型对比(MessagePack、Parquet、ProtoBuf 等)见专题 Python 文件 IO 与数据序列化 。
小结
json.dumps默认ensure_ascii=True,中文会转义;人读的文件设ensure_ascii=False+indent=2,并用 UTF-8 写入。default参数兜底datetime/Decimal等类型;JSON 往返会丢类型(tuple 变 list、dict 键只能是字符串)。csv.DictReader/DictWriter处理引号与字段内换行;读写都要传newline="",读回的字段全是字符串。csv.Sniffer能嗅探分隔符与表头,但只是启发式猜测。tomllib是 3.11+ 的只读 TOML 解析器,load只收二进制文件对象;写 TOML 需第三方库。- 反序列化不可信数据等于执行代码:
pickle.loads与yaml.load绝不可用于外部输入,YAML 一律safe_load;JSON 虽不执行代码,但有深嵌套栈溢出与超大整数两类资源耗尽风险。
到这里,第 10 章「字符串与文件 I/O」就完整了:文本在内存里用字符串方法与正则处理,落盘时用 pathlib 与正确的编码,结构化数据用 JSON/CSV/TOML 的标准库工具,边界处守住反序列化安全。下一章进入时间和日志——11.1 datetime、zoneinfo 与时区
会讲清时间戳、时区与夏令时这些最容易出错的细节。
阅读导航:上一节:10.2 文件 I/O、pathlib 与编码 · 下一节:11.1 datetime、zoneinfo 与时区 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。