文章
合集Python 标准库第 3 / 8 篇

Python 标准库 Day 2:数据序列化与配置管理

1. 问题引入

假设你正在搭一个个人博客网站,目录大概长这样:

my-blog/
├── config.toml          # 数据库 URL、端口、API key
├── data/
│   ├── posts.json       # 所有文章的元数据
│   └── visits.csv       # 访问日志(从 nginx 导出)
├── cache/
│   └── rendered.pkl     # 渲染好的 HTML 缓存
└── app.py

启动时你要做这几件事:

  1. 从 config.toml 读出端口号和数据库连接字符串
  2. 把 posts.json 加载成字典列表,方便按时间排序
  3. 把 visits.csv 解析出来,统计每篇文章的 PV
  4. 把渲染好的页面缓存起来,下次启动直接用

这四件事用的就是今天的五个库。每种格式都有擅长的场景,错配就是 bug 来源——比如有人把用户上传的数据 pickle.load,被 RCE 了;有人把中文 CSV 用默认编码写出来,Excel 打开全是乱码。

今天就把这五个库搞清楚,以及它们各自的边界。

2. 主题讲解

2.1 什么是序列化(Serialization)

先抓住核心概念:序列化 = 把内存里的对象,变成可以存盘或传输的字节/字符串;反序列化 = 反过来。

为什么需要它?三个原因:

  • 持久化:内存是临时的,断电就没了,要存到磁盘
  • 传输:网络只能传字节流,不能直接传 Python 对象
  • 跨语言:你的 Python 程序要和 JavaScript 前端、Go 微服务对话,需要一种公共语言

不同的格式在这几个维度上做了不同取舍:

格式人类可读跨语言类型丰富安全典型用途
JSON是是只有基础类型是API、配置、数据交换
CSV是是全是字符串是表格、报表
INI是有限否是老式配置
Pickle否否 Python 专属几乎任何对象危险缓存、模型存储
TOML是是比 JSON 多是现代 Python 配置

记住这张表,后面挑库的时候就不会乱。

2.2 json —— 互联网的通用语言

JSON 来自 JavaScript(JavaScript Object Notation),但今天它是事实上的跨语言数据交换标准。Python 标准库里的 json 模块只有四个核心 API:

json.dumps(obj)   # Python 对象 → JSON 字符串
json.loads(s)     # JSON 字符串 → Python 对象
json.dump(obj, f) # Python 对象 → 写入文件
json.load(f)      # 文件 → Python 对象

记忆法:带 s 的处理 string,不带的处理 file。

类型映射(这个一定要熟):

Python              JSON
─────────────────────────────
dict          →     object
list, tuple   →     array
str           →     string
int, float    →     number
True/False    →     true/false
None          →     null

注意 tuple 会被反序列化成 list——这是 JSON 不支持元组的副作用,第一次踩到会很懵。

常见误区

误区一:以为 JSON 支持所有 Python 类型

import json
from datetime import datetime

data = {"created_at": datetime.now()}
json.dumps(data)

datetime、set、bytes、Decimal、自定义类——这些 JSON 全都不认。解决办法是传 default= 参数告诉它怎么处理:

def to_serializable(obj):
    if isinstance(obj, datetime):
        return obj.isoformat()
    raise TypeError(f"{type(obj)} not serializable")

json.dumps(data, default=to_serializable)

误区二:dict 的 key 必须是字符串

json.dumps({1: "a"})  # OK,但会变成 {"1": "a"}
json.dumps({(1,2): "a"})  # TypeError

JSON 规范规定 object 的 key 必须是字符串,所以即使你的 Python dict 用 int 当 key,序列化后也会被转成字符串。反序列化回来时是字符串而不是 int,这是经典的双向不一致问题。

误区三:中文乱码

json.dumps({"name": "张三"})

默认会把非 ASCII 转成 \uXXXX。要保留中文:

json.dumps({"name": "张三"}, ensure_ascii=False)

写文件时还要记得指定 encoding="utf-8",否则 Windows 默认 GBK 会再坑你一次。

2.3 csv —— 看似简单实则陷阱密布

CSV 是最古老的数据格式之一(1972 年就有了),看起来就是「逗号分隔值」嘛,但在工程实践里坑很多:

  • 字段里包含逗号怎么办?→ 用引号包起来
  • 字段里包含引号怎么办?→ 引号转义成两个引号
  • 字段里包含换行怎么办?→ 也用引号包起来,但很多解析器处理不好
  • 用 tab 还是逗号?→ 这叫 dialect(方言)
  • 编码问题?→ 中文 CSV 在 Excel 里默认是 GBK,在 Mac 上又是另一回事

csv 模块就是来处理这些麻烦的。最好用的是 DictReader 和 DictWriter,把每行当字典处理:

import csv

with open("visits.csv", encoding="utf-8", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["url"], row["timestamp"])

三个必须知道的坑

坑一:永远要写 newline=""

这是 Python 文档明确规定的。如果不写,在 Windows 上会出现空行,因为 csv 模块自己处理换行符,OS 又来帮一次倒忙,结果就是 \r\r\n。

坑二:编码问题要预判

  • 从 Excel 另存为的 CSV 在 Windows 通常是 gbk 或 utf-8-sig(带 BOM 的 UTF-8)
  • Linux 工具产出的一般是纯 utf-8
  • Mac Excel 又有自己的脾气

读不确定来源的 CSV 时,可以先用 utf-8-sig(它能兼容带 BOM 和不带 BOM 两种情况),不行再试 gbk。

坑三:所有字段都是字符串

row = {"age": "25", "score": "98.5"}  # 注意这都是 str
age = int(row["age"])  # 要自己转

CSV 没有类型概念。这是它的简单也是它的局限。

2.4 configparser —— 老派的 INI 配置

INI 文件长这样:

[database]
host = localhost
port = 5432
user = admin

[logging]
level = INFO
file = /var/log/app.log

读取:

import configparser

config = configparser.ConfigParser()
config.read("app.ini")

print(config["database"]["host"])      # 'localhost'
print(config.getint("database", "port"))  # 5432,注意要用 getint

关键认知

所有值默认都是字符串,要数字得用 getint()、getfloat()、getboolean()。这一点和 CSV 一样。

getboolean 接受的真值串:yes/no、true/false、on/off、1/0,不区分大小写。这是它比手写 value == "true" 强的地方。

支持插值(interpolation),可以引用其他值:

[paths]
home = /home/alice
data = ${home}/data

为什么它在被淘汰

INI 格式的根本缺陷:没有嵌套,没有数组,没有类型。要表达 servers = ["a", "b", "c"] 怎么办?只能写成 servers = a,b,c 然后自己 split——这已经是格式之外的约定了。

所以 Python 社区在 2016 年的 PEP 518 选了 TOML 作为新标准,2022 年 Python 3.11 把 TOML 解析器搬进了标准库(tomllib)。

configparser 还会用在哪?

  • 维护老项目(Django 早期、Flask 一些扩展、setup.cfg)
  • 系统级配置(/etc/ 下的 ini 文件)
  • 给非程序员看的配置(INI 比 TOML 更直白)

新项目,优先 TOML。

2.5 pickle —— 强大但危险的二进制序列化

pickle 是 Python 专属的二进制序列化格式,几乎任何 Python 对象都能 pickle:函数引用、类实例、嵌套结构、numpy 数组……

import pickle

data = {"users": [1, 2, 3], "model": some_complex_object}

with open("cache.pkl", "wb") as f:  # 注意是 wb 二进制写
    pickle.dump(data, f)

with open("cache.pkl", "rb") as f:
    data = pickle.load(f)

安全警告(重点)

永远不要 pickle.load 来源不可信的数据。

为什么?因为 pickle 的反序列化过程允许执行任意代码。一个恶意构造的 pickle 文件,load 的瞬间就能在你的服务器上 rm -rf /。


import pickle, os

class Exploit:
    def __reduce__(self):
        return (os.system, ('echo PWNED',))

payload = pickle.dumps(Exploit())
pickle.loads(payload)  # 会执行 echo PWNED

判断标准:如果数据来自网络、用户上传、第三方 API,不要用 pickle,改用 JSON。

pickle 的合理用途

  • 本地缓存(你自己生成、自己读)
  • 多进程间通信(multiprocessing 内部就是用 pickle)
  • 机器学习模型存储(sklearn、PyTorch 早期都用 pickle,PyTorch 现在推荐 torch.save,本质也是 pickle)
  • functools.cache 持久化的辅助

vs JSON 速记

picklejson
可读否是
跨语言否是
支持类型几乎全部只有基础类型
安全否是
速度通常更快较慢
适用场景本地缓存数据交换、配置

2.6 tomllib —— Python 3.11+ 的配置新标准

TOML(Tom's Obvious, Minimal Language)是 GitHub 创始人 Tom Preston-Werner 在 2013 年设计的,目标是「比 INI 更强、比 YAML 更简单、比 JSON 更适合人写」。


title = "我的博客"
port = 8000
debug = true

[database]
host = "localhost"
port = 5432
users = ["alice", "bob"]

[server.tls]
cert = "/etc/ssl/cert.pem"

读取(注意:必须用二进制模式打开):

import tomllib  # Python 3.11+

with open("config.toml", "rb") as f:
    config = tomllib.load(f)

print(config["database"]["host"])  # 'localhost'
print(config["database"]["users"])  # ['alice', 'bob']  ← 真正的列表

关键认知

tomllib 只能读不能写。这是 Python 标准库的有意设计——保持最小职责。要写 TOML 文件,需要第三方库:

  • tomli-w:纯写入,简单
  • tomlkit:保留注释、格式,编辑器友好

如果你的项目目标是「读配置」(绝大多数场景),tomllib 够用了。

为什么打开要用 "rb"? TOML 规范规定文件必须是 UTF-8。tomllib 自己处理解码,所以不允许你用文本模式打开(不然双重解码会出问题)。这个设计其实是从社区库 tomli 继承来的。

为什么 TOML 赢了

2016 年的 PEP 518 选 TOML 作为 pyproject.toml 的格式时,候选包括 YAML、JSON、INI。最终选 TOML 的理由:

  • 比 YAML 简单:YAML 规范有 80 多页,缩进敏感、布尔陷阱(no 会被解析成 False,挪威国家代码 NO 翻车过)
  • 比 JSON 适合人写:JSON 不能写注释,多一个逗号就报错
  • 比 INI 强:原生支持嵌套、列表、日期时间等类型

今天你看到的 pyproject.toml、mypy 配置、black 配置、ruff 配置都用 TOML,这是 Python 生态的事实标准了。

3. Maybe Useful 旁注

旁注 1:JSON 为什么不支持注释? Douglas Crockford(JSON 发明人)有意拿掉的。他的原话大意是「我看到有人用注释来传递解析指令,破坏了互操作性,所以我把它拿掉了」。这就是为什么大家被迫发明了 JSON5、JSONC 这些扩展。

旁注 2:CSV 的「方言」是真实存在的csv 模块里有 csv.list_dialects(),能看到内置的 excel、excel-tab、unix。你也能注册自己的方言。这个抽象在你需要兼容多个 ERP 系统导出文件时特别有用。

旁注 3:pickle 的协议版本 pickle 有 0~5 共 6 个协议版本。Python 3.8 默认是 5,向前兼容旧版本但不能向后。跨 Python 版本传 pickle 时要显式 protocol=,不然新版本写的、旧版本读不出来。

旁注 4:面试常考——JSON 和 pickle 的区别 不要只答「一个文本一个二进制」。重点要答到:安全性(pickle 危险)、跨语言(pickle 不行)、类型丰富度(pickle 强)、适用场景(JSON 用于交换,pickle 用于本地缓存)。

旁注 5:YAML 不在标准库里 经常有人问「Python 怎么读 YAML」,答案是装 PyYAML。标准库不收 YAML 是因为它规范太复杂、安全问题多(yaml.load 也有代码执行风险,要用 yaml.safe_load)。这也反向印证了 TOML 的设计选择。

旁注 6:调试 JSON 的神器 命令行里 python -m json.tool input.json 可以格式化 JSON。配合 jq(不是 Python 的,但很常用)做 JSON 流处理,是数据工程师必备技能。

旁注 7:csv.field_size_limit 默认 CSV 单字段长度有上限(131072)。处理某些超大字段(比如把 base64 图片塞进 CSV 的奇葩场景)时会报 _csv.Error: field larger than field limit。解决:csv.field_size_limit(sys.maxsize)。这个坑很冷门但一旦遇到就抓狂。

4. 代码实践

我们围绕开头的博客场景写一个完整的 demo:读 TOML 配置 → 加载 JSON 文章 → 解析 CSV 访问日志 → 统计 PV → 输出 JSON 报表 → 缓存到 pickle。

这一段代码体现了今天五个库的协作。

"""
blog_stats.py - 博客访问统计工具
功能:合并文章元数据 + 访问日志,输出每篇文章的 PV 统计
"""
import json
import csv
import pickle
import tomllib
from datetime import datetime
from pathlib import Path
from collections import Counter


def load_config(path: Path) -> dict:
    with open(path, "rb") as f:  # 注意 "rb"
        return tomllib.load(f)


def load_posts(path: Path) -> list[dict]:
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def count_visits(path: Path) -> Counter:
    counter = Counter()
    with open(path, encoding="utf-8-sig", newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            counter[row["url"]] += 1  # 累加每个 URL 的访问次数
    return counter


def build_report(posts: list[dict], visits: Counter) -> dict:
    return {
        "generated_at": datetime.now().isoformat(),  # datetime 不能直接 JSON 化,转成字符串
        "total_visits": sum(visits.values()),
        "posts": [
            {
                "title": p["title"],
                "url": p["url"],
                "pv": visits.get(p["url"], 0),
            }
            for p in posts
        ],
    }


def save_outputs(report: dict, json_path: Path, cache_path: Path):
    # 5.1 确保父目录存在
    json_path.parent.mkdir(parents=True, exist_ok=True)
    cache_path.parent.mkdir(parents=True, exist_ok=True)
    # JSON:人类可读,可以给前端
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    # pickle:自己下次启动时秒速加载
    with open(cache_path, "wb") as f:
        pickle.dump(report, f)


def main():
    config = load_config(Path("config.toml"))
    posts = load_posts(Path(config["paths"]["posts"]))
    visits = count_visits(Path(config["paths"]["visits"]))
    report = build_report(posts, visits)
    save_outputs(
        report,
        Path(config["paths"]["report"]),
        Path(config["paths"]["cache"]),
    )
    print(f"报表生成完毕,共统计 {report['total_visits']} 次访问")

if __name__ == "__main__":
    main()

配套的几个数据文件(你可以自己创建测试):


[paths]
posts   = "data/posts.json"
visits  = "data/visits.csv"
report  = "output/report.json"
cache   = "cache/report.pkl"
[
  {"title": "Python 入门", "url": "/python-intro"},
  {"title": "标准库精讲", "url": "/stdlib"}
]
url,timestamp,ip
/python-intro,2026-04-28T10:00,1.1.1.1
/stdlib,2026-04-28T10:05,2.2.2.2
/python-intro,2026-04-28T10:10,3.3.3.3

这段代码值得注意的点

  1. Path 来自 Day 1 的 pathlib——配置里读出来是字符串,立刻包装成 Path,后续操作就跨平台了
  2. utf-8-sig 读 CSV——兼容带 BOM 和不带 BOM 两种情况,是个稳妥的默认
  3. datetime.now().isoformat()——把 datetime 转成 ISO 8601 字符串,JSON 才能吃下去
  4. ensure_ascii=False, indent=2——中文不转义,缩进 2 格,生成的 JSON 文件能直接 review
  5. JSON 给外部,pickle 给自己——这是最常见的双输出模式:JSON 用来对接前端/API,pickle 用来下次启动秒速加载

补充说明:open() 的 mode 参数有默认值"r"(文本读取模式),所以 open(path, encoding="utf-8") 等价于 open(path, mode="r", encoding="utf-8")。而 tomllib.load() 和 pickle.load() 要求二进制文件对象,所以必须显式写 "rb"。

open 的 "w" 模式只会创建文件本身,不会创建中间目录。如果 output/ 或 cache/ 不存在,open(..., "w") 会抛 FileNotFoundError。解决方案:

json_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.parent.mkdir(parents=True, exist_ok=True)

两个参数的记忆口诀:「P 给我建全(parents),E 给我闭嘴(exist_ok)」。

"w" 模式打开文件会立刻清空原文件,即使后面写入失败。健壮的做法是「写临时文件 + 原子重命名」:

import os
import tempfile

def atomic_write_json(path: Path, data: dict):
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(
        mode="w", encoding="utf-8",
        dir=path.parent, delete=False,
        suffix=".tmp"
    ) as tmp:
        json.dump(data, tmp, ensure_ascii=False, indent=2)
        tmp_path = tmp.name
    os.replace(tmp_path, path)  # 原子替换

os.replace() 在 POSIX 和 Windows 上都是原子操作。这个模式叫 write-rename pattern,Git、SQLite、各种数据库的写盘都用类似套路。

5. 练习题

理论题

  1. 你有一个字典 {"items": {1, 2, 3}},能直接 json.dumps 吗?为什么?怎么解决?
回答

不能。

{1, 2, 3} 是 Python 的 set(集合),而 JSON 规范里没有 set 这种数据类型。JSON 只支持:object、array、string、number、boolean、null。所以 json 模块不知道该把 set 序列化成什么。

实际报错:

>>> json.dumps({"items": {1, 2, 3}})
TypeError: Object of type set is not JSON serializable

怎么解决(三种思路):

方案 A:先转成 list(最简单,最常用)

data = {"items": {1, 2, 3}}
data["items"] = list(data["items"])
json.dumps(data)

方案 B:用 default= 参数(适合数据结构里很多地方都有 set)

def to_serializable(obj):
    if isinstance(obj, set):
        return list(obj)
    raise TypeError(f"{type(obj)} not serializable")

json.dumps({"items": {1, 2, 3}}, default=to_serializable)

方案 C:default=list(一行简洁版,但只对可迭代对象有效)

json.dumps({"items": {1, 2, 3}}, default=list)

注意一个隐藏问题:set 转 list 后顺序不固定。如果需要稳定顺序,先 sorted():

data["items"] = sorted(data["items"])
  1. 解释为什么 pickle.load 不安全,并举一个你在工作中可能会接触到 pickle 的场景。
回答

pickle 反序列化的过程会执行任意代码。pickle 的本质不是「读数据」,而是「按照 pickle 字节流里的指令,重建对象」——而这些指令包括「调用某个函数」。攻击者可以构造一个恶意的 pickle 文件,让 pickle.load 在你机器上执行任意 Python 代码。

判断标准(这一条最重要):如果数据来源不是你自己 100% 控制的,永远不要 pickle.load。包括:网络下载的、用户上传的、第三方 API 返回的、邮件附件的。

工作中可能接触 pickle 的场景:

  • 本地缓存:把昂贵的计算结果 pickle 到磁盘,下次直接读
  • 机器学习模型:sklearn 的 joblib.dump 底层就是 pickle;PyTorch 的 torch.save 也是
  • 多进程通信:Python multiprocessing 模块在进程间传数据,底层用 pickle
  • 任务队列:Celery 默认用 pickle 序列化任务参数(注意:Celery 后来推荐换成 JSON,原因就是安全)
  • Redis/Memcached 缓存复杂对象

以上场景的共同点是「数据是自己产生、自己消费」,所以可信。

  1. tomllib.load(open("config.toml")) 这行代码会报错,为什么?正确写法是什么?
回答

会报错:

TypeError: File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`

原因:tomllib.load() 要求接收二进制文件对象("rb" 模式打开),不接受文本模式。

为什么这么设计:TOML 规范明确规定文件必须是 UTF-8。tomllib 想自己负责解码,不想让用户的文本模式干扰。

正确写法:

with open("config.toml", "rb") as f:
    config = tomllib.load(f)
  1. 写 CSV 时为什么必须 newline=""?不写会发生什么?
回答

不写会在 Windows 上出现多余的空行,导致 CSV 文件损坏。

原因(两层换行符处理):

  1. csv 模块自己处理换行。它在写入时会输出 \r\n 作为行分隔符
  2. 文本模式的 open 也会处理换行。Windows 上,文本模式会把你写入的 \n 自动转成 \r\n

两层叠加:csv 模块写 \r\n → 文本模式再转 → \r\r\n,变成两个换行。

newline="" 告诉 open:「别管换行,原样输出」。

  1. configparser 读出来的 port = 5432,类型是什么?怎么拿到 int?
回答

类型是 str——字符串 "5432",不是整数。

怎么拿 int(推荐方法):

port = config.getint("database", "port")

configparser 提供了一组类型转换方法:getint()、getfloat()、getboolean()。getboolean 尤其有用,因为它认得 "yes"、"on"、"1" 这些常见写法。

代码实操题

题目 A:配置迁移工具 写一个脚本,把一个 INI 配置文件转换成等价的 TOML 配置文件。

题目 B:CSV 健康检查器 写一个工具,扫描一个 CSV 文件并输出报告:行数、列数、每列有几个空值、每列推断的数据类型。结果输出成 JSON。

题目 C:安全的缓存装饰器 写一个 @disk_cache(path) 装饰器,把函数的返回值用 pickle 缓存到磁盘。

思考题

你的博客访问量上来了,每天访问日志(CSV)有 1GB。原来的脚本一次性 csv.DictReader 全读进内存,OOM 了。你会怎么改造它?涉及到哪些权衡?如果同时要保证「断点续传」,你会用什么格式记录进度状态?是 JSON、pickle、还是另一种?为什么?

6. 当天总结

今天学了什么:

五个序列化/配置库,分别对应五种场景:

  • json —— 跨语言交换,API 必备
  • csv —— 表格数据,注意编码和 newline
  • configparser —— 老派配置,新项目慎选
  • pickle —— Python 专属缓存,永不接受外部输入
  • tomllib —— 现代 Python 配置新标准,只读

最重要的概念:

  1. "该用什么格式"是设计问题——根据「谁来读」「跨不跨语言」「是否要给人看」来选
  2. pickle 的安全边界——这是最容易在工作中出严重事故的点
  3. JSON 的类型映射——tuple → list、int key → str key、datetime 不支持
  4. CSV 的两个魔法字符串:newline="" 和 encoding="utf-8-sig"

需要反复练习:

  • JSON 处理自定义类型(default= 和 object_hook=)
  • CSV 在不同编码、不同方言下的读写
  • pickle 安全意识——看到陌生的 .pkl 文件,要警惕

和明天的衔接:

明天 Day 3 讲 datetime / time / zoneinfo。你可能注意到今天 demo 里用了 datetime.now().isoformat()——这个 ISO 字符串格式、时区问题、JSON 里时间该怎么存,都是明天要展开的。