Python 基础体系 · 第 39/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 结构化数据:JSON、CSV、TOML、Schema 与精度边界

结构化数据处理的难点通常不在“如何读一个文件”,而在于回答以下问题:

  1. 数据的结构由什么定义?
  2. 字段的类型在哪里确定?
  3. 空值、缺失值和默认值是否等价?
  4. 数字在读写过程中是否保持精确?
  5. 解析器返回的是可信对象,还是仍需校验的原始数据?
  6. 数据经过复制、序列化、反序列化后,语义是否发生变化?

JSON、CSV 和 TOML 解决的是不同层次的问题:

  • JSON:通用的树形数据交换格式。
  • CSV:二维表格的文本表示。
  • TOML:强调可读性的配置文件格式。
  • Schema:描述数据约束的规则,而不是一种具体文件格式。
  • 精度边界:数据类型在不同表示、语言和系统之间转换时可能丢失信息的边界。

理解这几个概念,需要把数据流拆成几个阶段:

flowchart LR
    A[文本或字节] --> B[语法解析]
    B --> C[Python 对象]
    C --> D[Schema 校验]
    D --> E[领域对象]
    E --> F[业务计算]
    F --> G[序列化输出]

    B -.可能失败.-> X[语法错误]
    D -.可能失败.-> Y[类型或约束错误]
    G -.可能失败.-> Z[不可表示或精度损失]

例如,一个 JSON 文档首先要满足 JSON 语法;解析后得到的 dictlist 仍然可能缺少字段、类型错误或违反业务约束;即使校验通过,输出到另一个系统时仍可能因为数字范围或浮点表示方式而改变含义。


一、先区分四个概念:格式、解析、模型与校验

1. 数据格式

数据格式规定数据如何表示。例如:

{
  "name": "Alice",
  "age": 30
}

这是 JSON 文本。它规定了对象、数组、字符串、数字、布尔值和空值的语法。

CSV 则可能是:

name,age
Alice,30

它表达的是表格,而不是任意嵌套树。

TOML 可以写成:

name = "Alice"
age = 30

它主要用于配置。

2. 解析

解析是把文本转换为程序可以操作的对象。

import json

data = json.loads('{"name": "Alice", "age": 30}')

print(data)
print(type(data))

输出:

{'name': 'Alice', 'age': 30}
<class 'dict'>

解析器只回答:

这段文本是否符合格式,以及它对应什么基础对象?

它不回答:

  • age 是否必须存在;
  • age 是否允许为负数;
  • name 是否允许为空;
  • 两个字段之间是否满足某种关系。

3. 模型

模型是程序内部对数据结构的明确表示。例如:

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

模型表达了程序希望如何使用数据,但类型标注本身不一定在运行时执行校验:

user = User(name="Alice", age="thirty")
print(user)

输出仍然可能是:

User(name='Alice', age='thirty')

dataclass 默认负责生成初始化方法、表示方法等代码,不会自动把 "thirty" 校验成整数或拒绝它。

4. Schema

Schema 是对数据形状和约束的机器可读描述。例如,可以用伪形式表示用户数据:

User:
  name: string,必填,长度至少为 1
  age: integer,必填,范围为 0 到 150

Schema 可以用于:

  • 校验输入;
  • 生成文档;
  • 生成测试数据;
  • 驱动代码生成;
  • 约束接口兼容性。

重要的是:

JSON 是数据格式,JSON Schema 是描述 JSON 数据的约束语言。两者不是同一个东西。

一个 JSON 文档可以没有 Schema;一个 Schema 也不包含具体用户数据。


二、JSON:树形数据交换格式

2.1 JSON 的数据模型

Python 标准库 json 模块默认支持如下映射:

JSON Python
object dict
array list
string str
integer int
real number float
true True
false False
null None

Python 的 tuple 在序列化时会被当作 JSON 数组处理,反序列化后则通常得到 list。因此,JSON 往返并不保证保留所有 Python 类型。(docs.python.org)

import json

value = {
    "name": "Alice",
    "roles": ("admin", "reader"),
    "enabled": True,
    "quota": None,
}

text = json.dumps(value, ensure_ascii=False)
restored = json.loads(text)

print(text)
print(restored)
print(type(restored["roles"]))

输出类似:

{"name": "Alice", "roles": ["admin", "reader"], "enabled": true, "quota": null}
{'name': 'Alice', 'roles': ['admin', 'reader'], 'enabled': True, 'quota': None}
<class 'list'>

这里发生了两个转换:

tuple -> JSON array -> list
None  -> null      -> None

如果业务依赖元组的不可变性质,不能把 JSON 往返当作普通的类型保留机制。


2.2 dumpdumpsloadloads

四个函数可以按“字符串”和“文件对象”分成两组:

函数 输入或输出
json.dumps(obj) Python 对象 -> JSON 字符串
json.loads(text) JSON 字符串 -> Python 对象
json.dump(obj, fp) Python 对象 -> 文件
json.load(fp) 文件 -> Python 对象

示例:

import json
from pathlib import Path

payload = {
    "version": 1,
    "items": ["a", "b"],
}

path = Path("payload.json")

with path.open("w", encoding="utf-8") as fp:
    json.dump(
        payload,
        fp,
        ensure_ascii=False,
        indent=2,
        sort_keys=True,
    )

with path.open("r", encoding="utf-8") as fp:
    loaded = json.load(fp)

print(loaded)

其中:

  • ensure_ascii=False 让非 ASCII 字符直接写出;
  • indent=2 只改变可读性,不改变数据语义;
  • sort_keys=True 让键顺序稳定,适合测试、差异比较和生成签名前的规范化场景。

json 模块输出的是 str,不是 bytes;因此传给 dump() 的文件对象必须接受字符串写入。(docs.python.org)

不要重复向同一个 JSON 文件调用 dump

JSON 不是带边界的多对象协议。下面的写法会生成无效 JSON:

import json

with open("bad.json", "w", encoding="utf-8") as fp:
    json.dump({"id": 1}, fp)
    json.dump({"id": 2}, fp)

文件内容类似:

{"id": 1}{"id": 2}

它不是一个 JSON 文档,而是两个 JSON 文档直接拼接。标准库文档明确指出,JSON 不是 framed protocol,重复调用 dump() 到同一文件会产生无效文件。(docs.python.org)

如果需要连续传输多个对象,应明确选择协议,例如:

一行一个 JSON 对象:JSON Lines / NDJSON

此时每一行是一个独立 JSON 文档:

{"id": 1}
{"id": 2}

读取时按行解析:

import json

with open("events.ndjson", encoding="utf-8") as fp:
    for line_number, line in enumerate(fp, start=1):
        if not line.strip():
            continue

        try:
            event = json.loads(line)
        except json.JSONDecodeError as exc:
            raise ValueError(
                f"第 {line_number} 行不是有效 JSON"
            ) from exc

        print(event)

这不是 json 模块额外提供的容器格式,而是应用层定义的“记录边界”。


2.3 JSON 键的类型会发生变化

JSON 对象的键必须是字符串。Python dict 虽然允许整数、浮点数、布尔值和 None 作为键,但序列化时这些键会被转换为字符串。

import json

original = {
    1: "integer key",
    "1": "string key",
}

text = json.dumps(original)
restored = json.loads(text)

print(text)
print(restored)
print(original == restored)

可能得到:

{"1": "integer key", "1": "string key"}
{'1': 'string key'}
False

这里有两个问题:

  1. 整数键 1 被转换成字符串 "1"
  2. 转换后与原本存在的字符串键冲突,前一个值被后一个值覆盖。

Python 文档也明确说明,JSON 对象的键总是字符串,loads(dumps(x)) != x 可能成立,尤其是原字典含有非字符串键时。(docs.python.org)

因此,面向跨系统的数据结构应优先使用字符串键,而不要依赖 Python 字典的特殊键类型。


2.4 default 不是“任意对象序列化”

标准 JSON 编码器不认识 datetimeDecimal、集合和自定义类:

from datetime import datetime
import json

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

会抛出:

TypeError: Object of type datetime is not JSON serializable

可以用 default 明确规定转换:

from datetime import datetime, timezone
from decimal import Decimal
import json

def encode_extra(obj):
    if isinstance(obj, datetime):
        return obj.astimezone(timezone.utc).isoformat()
    if isinstance(obj, Decimal):
        return {
            "$decimal": format(obj, "f"),
        }
    raise TypeError(f"unsupported type: {type(obj).__name__}")

payload = {
    "created_at": datetime(2026, 9, 1, tzinfo=timezone.utc),
    "amount": Decimal("12.30"),
}

text = json.dumps(payload, default=encode_extra)
print(text)

输出类似:

{"created_at": "2026-09-01T00:00:00+00:00", "amount": {"$decimal": "12.30"}}

default 的职责是:

把当前对象转换成一个本身可以被 JSON 编码的对象。

它不是自动保存 Python 类型信息的机制。如果输出一个普通字符串,反序列化时无法仅凭字符串判断它原来是 datetime 还是普通文本。

需要双向还原时,必须设计明确的标记协议:

import json
from decimal import Decimal

def decode_extra(obj):
    if set(obj) == {"$decimal"}:
        return Decimal(obj["$decimal"])
    return obj

restored = json.loads(
    '{"amount": {"$decimal": "12.30"}}',
    object_hook=decode_extra,
)

print(restored)
print(type(restored["amount"]))

输出:

{'amount': Decimal('12.30')}
<class 'decimal.Decimal'>

这个协议的风险是:如果外部输入可以构造类似的标记对象,解码逻辑就必须把它当作不可信数据处理,不能让“类型标记”触发任意函数调用。


2.5 JSON 的严格性边界

Python 的 json 模块默认允许一些并非严格 JSON 标准内容:

import json

print(json.dumps(float("nan")))
print(json.dumps(float("inf")))

print(json.loads("NaN"))
print(json.loads("Infinity"))

输出:

NaN
Infinity
nan
inf

如果需要严格拒绝这些值:

import json

try:
    print(json.dumps(float("nan"), allow_nan=False))
except ValueError as exc:
    print(type(exc).__name__, exc)

allow_nan=False 会在序列化 NaNInfinity-Infinity 时抛出 ValueError;反序列化方向可以使用 parse_constant 自定义处理。(docs.python.org)

import json

def reject_nonstandard_number(value):
    raise ValueError(f"非法 JSON 数字: {value}")

try:
    json.loads("NaN", parse_constant=reject_nonstandard_number)
except ValueError as exc:
    print(exc)

重复键

以下 JSON 在语法上可能被解析器接受:

{"status": "pending", "status": "paid"}

Python 默认保留最后一个值:

import json

print(json.loads('{"status": "pending", "status": "paid"}'))

输出:

{'status': 'paid'}

Python 文档说明,默认情况下重复名称中只有最后一个键值对会被使用;如果需要自定义处理,可以使用 object_pairs_hook。(docs.python.org)

可以用它检测重复键:

import json

def reject_duplicate_keys(pairs):
    result = {}

    for key, value in pairs:
        if key in result:
            raise ValueError(f"重复 JSON 键: {key!r}")
        result[key] = value

    return result

try:
    json.loads(
        '{"x": 1, "x": 2}',
        object_pairs_hook=reject_duplicate_keys,
    )
except ValueError as exc:
    print(exc)

这类检查在配置文件、权限策略和签名数据中尤其重要,因为“取第一个”还是“取最后一个”可能造成不同组件之间的解释差异。


三、CSV:表格结构,不是通用对象结构

3.1 CSV 的核心模型

CSV 可以抽象成:

记录序列 -> 每条记录包含字段序列

例如:

id,name,amount
1,Alice,12.30
2,Bob,8.50

它天然适合:

  • 数据库导出;
  • 电子表格交换;
  • 批量导入;
  • 分析工具之间的二维数据交换。

但 CSV 没有统一、严格覆盖所有实现的标准。不同程序可能在分隔符、引号、换行符和转义规则上存在差异,因此 Python 的 csv 模块通过 dialect 抽象这些差别。(docs.python.org)

CSV 的一个根本特征是:

CSV 记录通常只有文本字段;数字、日期、布尔值和空值的含义需要由应用程序或外部 Schema 解释。


3.2 正确打开 CSV 文件

写 CSV 时通常应使用 newline=""

import csv

rows = [
    {"id": 1, "name": "Alice", "amount": "12.30"},
    {"id": 2, "name": "Bob", "amount": "8.50"},
]

with open("users.csv", "w", encoding="utf-8", newline="") as fp:
    writer = csv.DictWriter(
        fp,
        fieldnames=["id", "name", "amount"],
    )
    writer.writeheader()
    writer.writerows(rows)

读取:

import csv

with open("users.csv", "r", encoding="utf-8", newline="") as fp:
    reader = csv.DictReader(fp)

    for row in reader:
        print(row)

输出:

{'id': '1', 'name': 'Alice', 'amount': '12.30'}
{'id': '2', 'name': 'Bob', 'amount': '8.50'}

注意:DictReader 给出的字段仍然是字符串。它不会因为字段名叫 idamount 就自动完成类型转换。

显式转换:

from decimal import Decimal
import csv

with open("users.csv", encoding="utf-8", newline="") as fp:
    reader = csv.DictReader(fp)

    for row_number, row in enumerate(reader, start=2):
        try:
            user_id = int(row["id"])
            amount = Decimal(row["amount"])
        except (KeyError, ValueError):
            raise ValueError(f"第 {row_number} 行字段类型错误")

        print(user_id, amount)

这里使用 Decimal(row["amount"]) 而不是 Decimal(float(row["amount"])),原因在后文解释。


3.3 CSV 的引号和换行

字段中包含逗号、引号或换行时,不能手工使用 split(",")

line = '1,"Shanghai, China","hello\nworld"'
print(line.split(","))

结果会错误地拆成多个字段,并且无法正确处理换行记录。

应使用 csv.reader

import csv
from io import StringIO

text = '1,"Shanghai, China","hello\nworld"\n'

reader = csv.reader(StringIO(text))

for row in reader:
    print(row)

输出:

['1', 'Shanghai, China', 'hello\nworld']

CSV 的逻辑记录可能跨越多行,因此 reader.line_num 表示已经读取的物理行数,不一定等于已经返回的记录数。(docs.python.org)


3.4 QUOTE_NONNUMERIC 的边界

csv.reader(..., quoting=csv.QUOTE_NONNUMERIC) 可以把未加引号的字段转换为浮点数:

import csv
from io import StringIO

text = "1,12.30\n"

reader = csv.reader(
    StringIO(text),
    quoting=csv.QUOTE_NONNUMERIC,
)

print(next(reader))

输出类似:

[1.0, 12.3]

这通常不适合金额字段,因为:

  1. 1 被转换为 1.0
  2. 12.30 的展示尺度丢失;
  3. 使用二进制浮点数,可能引入精度问题。

如果字段含义重要,建议让 CSV 读取阶段保持字符串,再由 Schema 或显式转换函数决定类型。


3.5 CSV 的空值、缺失值和空字符串

以下三种情况不应默认视为同一个值:

id,name,comment
1,Alice,
2,Bob,""
3,Carol

它们可能分别表示:

  • 空字段;
  • 显式空字符串;
  • 缺少列;
  • 文件末尾换行导致的视觉差异。

csv.DictReader 对缺失列和多余列有自己的行为,但业务含义仍需由应用定义。常见做法是先检查列集合:

import csv

required = {"id", "name", "amount"}

with open("users.csv", encoding="utf-8", newline="") as fp:
    reader = csv.DictReader(fp)

    actual = set(reader.fieldnames or [])
    missing = required - actual

    if missing:
        raise ValueError(f"缺少列: {sorted(missing)}")

    for row in reader:
        if row["amount"] == "":
            raise ValueError("amount 不能为空")

“字段存在但值为空”和“字段根本不存在”通常代表不同的更新语义。尤其在批量更新接口中:

缺失字段:保持旧值
字段为 null:清空字段
字段为空字符串:写入空字符串

不能依赖 CSV 或 JSON 解析器替业务做这项决定。


四、TOML:带类型的配置格式

4.1 TOML 与 JSON 的区别

TOML 的目标是让配置文件适合人类阅读和编辑:

debug = true
port = 8080
timeout = 1.5
name = "service-a"

[database]
host = "127.0.0.1"
port = 5432

与 CSV 相比,TOML 有明确的嵌套结构和基础类型;与 JSON 相比,TOML 的语法更适合书写配置,例如键值对、表和数组。

Python 3.11 起,标准库提供 tomllib;Python 3.14 的 tomllib 仍然只提供读取能力,不提供 TOML 写入 API。它实现的是 TOML 1.0.0 的解析接口。(docs.python.org)


4.2 tomllib.load 要求二进制文件

import tomllib

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

print(config)

输出类似:

{
    "debug": True,
    "port": 8080,
    "timeout": 1.5,
    "database": {
        "host": "127.0.0.1",
        "port": 5432,
    },
}

解析字符串则使用 loads

import tomllib

text = """
name = "service-a"
enabled = true
"""

config = tomllib.loads(text)
print(config)

使用 rb 而不是 r,是因为 tomllib.load() 接受可读的二进制文件对象;tomllib.loads() 接受字符串。


4.3 TOML 的类型映射

tomllib 的主要映射包括:

TOML 类型 Python 类型
文档 dict
字符串 str
整数 int
浮点数 float,可配置
布尔值 bool
offset datetime 带时区的 datetime
local datetime tzinfo=Nonedatetime
日期 date
时间 time
数组 list
dict
array of tables list[dict]

这些转换由标准库文档明确规定。(docs.python.org)

日期时间的一个重要边界是:

created_at = 2026-09-01T08:30:00+08:00
local_time = 2026-09-01T08:30:00

前者包含时区偏移:

import tomllib

config = tomllib.loads("""
created_at = 2026-09-01T08:30:00+08:00
local_time = 2026-09-01T08:30:00
""")

print(config["created_at"].tzinfo)
print(config["local_time"].tzinfo)

输出类似:

UTC+08:00
None

local_time 不是“自动使用本地时区的时间”,而是没有时区信息的本地日期时间。若系统把它当作 UTC、北京时间或服务器本地时间,必须由业务规则明确规定。


4.4 用 parse_float=Decimal 保留十进制输入

默认情况下,TOML 浮点数会被解析为 float。如果需要把文本中的十进制数直接交给 Decimal

from decimal import Decimal
import tomllib

config = tomllib.loads(
    "price = 0.1",
    parse_float=Decimal,
)

print(config)
print(type(config["price"]))

输出:

{'price': Decimal('0.1')}
<class 'decimal.Decimal'>

parse_float 会接收每个 TOML 浮点数的文本形式;默认相当于调用 float(num_str),也可以替换为 decimal.Decimal。该回调不能返回 dictlist。(docs.python.org)

错误的转换路径

from decimal import Decimal

bad = Decimal(float("0.1"))
good = Decimal("0.1")

print(bad)
print(good)
print(bad == good)

float("0.1") 先把十进制文本转换成二进制浮点近似值,之后 Decimal 只能精确表示这个近似值,而不是原始十进制文本。

正确的原则是:

文本金额 -> Decimal(文本)

而不是:

文本金额 -> float -> Decimal

4.5 TOML 只负责语法,不负责配置契约

TOML 解析成功,不代表配置可用:

port = "eight thousand"

这在 TOML 语法上可能是合法字符串,但程序要求的可能是整数端口。

因此配置加载应分为两步:

TOML 解析 -> 基础对象
基础对象 -> Schema 校验 -> 应用配置

标准库 tomllib 不会自动检查端口范围、路径是否存在、互斥选项是否同时开启等业务条件。


五、Schema:从“能解析”到“可使用”

5.1 Schema 的形式化含义

把输入数据看作对象 x,Schema 看作谓词 S(x)

S(x) = True  表示 x 满足约束
S(x) = False 表示 x 不满足约束

一个用户 Schema 可以写成:

S(user) =
    user 是对象
    且存在 name
    且 name 是字符串
    且 name 非空
    且存在 age
    且 age 是整数
    且 0 <= age <= 150

校验器的工作就是计算这个谓词,并在失败时返回结构化错误。

需要区分三种失败:

语法失败

{"name": "Alice",}

尾随逗号不符合标准 JSON 语法,属于解析错误。

类型失败

{"name": "Alice", "age": "30"}

JSON 语法合法,但 age 是字符串而不是整数。

业务约束失败

{"name": "Alice", "age": -3}

类型可能正确,但年龄范围不满足业务规则。

如果把这三类错误都简单包装成“数据格式错误”,诊断和恢复都会变得困难。


5.2 用标准库实现一个明确的校验边界

对于简单结构,可以不用第三方库,直接实现校验函数:

from typing import Any

class ValidationError(ValueError):
    pass

def validate_user(value: Any) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise ValidationError("user 必须是对象")

    required = {"name", "age"}
    missing = required - value.keys()
    if missing:
        raise ValidationError(f"缺少字段: {sorted(missing)}")

    name = value["name"]
    age = value["age"]

    if not isinstance(name, str):
        raise ValidationError("name 必须是字符串")
    if not name.strip():
        raise ValidationError("name 不能为空")

    # bool 是 int 的子类,因此不能只写 isinstance(age, int)
    if isinstance(age, bool) or not isinstance(age, int):
        raise ValidationError("age 必须是整数")
    if not 0 <= age <= 150:
        raise ValidationError("age 必须位于 0 到 150 之间")

    return {
        "name": name,
        "age": age,
    }

这里有一个常见反例:

isinstance(True, int)

结果是:

True

因为 Python 中 boolint 的子类。若 Schema 中的 integer 不允许布尔值,必须显式排除 bool

测试:

import json

raw = '{"name": "Alice", "age": true}'
value = json.loads(raw)

try:
    user = validate_user(value)
except ValidationError as exc:
    print(exc)

输出:

age 必须是整数

解析和校验被刻意分开:

value = json.loads(raw)       # 语法层
user = validate_user(value)   # 契约层

这样做的好处是:CSV、TOML 和 HTTP JSON 都可以复用同一个校验边界,只需分别实现输入解析。


5.3 Schema 不等于类型标注

类型标注主要服务于:

  • 静态检查器;
  • IDE;
  • 文档;
  • 开发者理解。

运行时 Schema 则服务于:

  • 外部输入;
  • 不可信数据;
  • 配置加载;
  • 接口边界;
  • 持久化恢复。

两者可以相互生成或配合,但不能自动视为同一件事:

def create_user(age: int) -> None:
    print(age)

create_user("30")

Python 默认不会因为注解是 int 就自动拒绝字符串。


5.4 Pydantic v2 作为运行时模型层

如果项目已经使用 Pydantic v2,可以把 Schema、校验和序列化集中在模型中:

from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field

class User(BaseModel):
    model_config = ConfigDict(strict=True)

    name: str = Field(min_length=1)
    age: int = Field(ge=0, le=150)
    balance: Decimal

解析并校验 JSON:

from pydantic import ValidationError

raw = b'{"name": "Alice", "age": 30, "balance": "12.30"}'

try:
    user = User.model_validate_json(raw)
except ValidationError as exc:
    print(exc)
else:
    print(user)
    print(user.model_dump())
    print(user.model_dump_json())

这里要注意三个层次:

  1. model_validate_json() 接收 JSON 数据并完成模型校验;
  2. user 是已经通过模型约束的 Python 对象;
  3. model_dump() 返回 Python 数据结构,model_dump_json() 负责 JSON 序列化。

Pydantic v2 使用 pydantic-core 处理核心校验和序列化;模型配置中的 strict=True 可要求字段采用更严格的验证方式。(docs.pydantic.dev)

但模型并不等于数据库事务,也不等于业务完整性。比如“用户名不能与数据库中已有用户重复”需要数据库或服务层参与,单个模型无法独立证明这一点。


六、精度边界:数据没有“自动保持原样”

6.1 二进制浮点数为什么不精确

计算机中的 float 通常采用二进制浮点表示。十进制的 0.1 不能有限地表示为二进制小数,因此 Python 保存的是接近 0.1 的二进制值。Python 官方教程用 1/3 的十进制无限展开类比说明了这种近似过程。(docs.python.org)

print(0.1 + 0.2)
print((0.1 + 0.2) == 0.3)

输出:

0.30000000000000004
False

更准确地说,问题不是 Python 把 0.1 “算错了”,而是:

十进制文本 0.1
    -> 二进制浮点近似值
    -> 运算
    -> 十进制展示

展示结果暴露了近似误差。


6.2 Decimal 解决哪一类问题

Decimal 适合表示需要十进制语义的数据,例如金额、税率和计费比例。它可以精确表示十进制输入,并通过上下文决定精度和舍入规则。Python 标准库将其定义为支持正确舍入的十进制固定点和浮点运算。(docs.python.org)

from decimal import Decimal

print(Decimal("0.1") + Decimal("0.2"))
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))

输出:

0.3
True

Decimal 并不意味着所有运算都无限精确:

from decimal import Decimal, getcontext

getcontext().prec = 4

print(Decimal("1") / Decimal("7"))

输出类似:

0.1429

因为上下文精度为 4 位有效数字,除法结果仍需要舍入。

所以应区分:

Decimal("12.30"):精确表示输入的小数
Decimal("1") / Decimal("7"):在指定精度下进行舍入

6.3 JSON 数字的跨语言边界

Python 的 int 支持任意精度整数,但 JSON 本身没有规定所有实现必须使用相同的整数表示。某些消费者会把 JSON 数字统一解析为 IEEE 754 双精度浮点数。

双精度浮点数能够无误表示的连续整数范围通常到:

-2^53 到 2^53

最大安全整数为:

2^53 - 1 = 9007199254740991

因此,下面这个 Python 整数在某些消费者中可能发生变化:

import json

value = 9007199254740993
text = json.dumps({"id": value})

print(text)

Python 自己解析仍能得到原值:

restored = json.loads(text)
print(restored["id"] == value)

但如果另一个系统先把它转换为双精度浮点数,就可能只能得到邻近可表示值。Python 文档特别提醒,JSON 消费者常把数字解析为 IEEE 754 双精度数,因此极大整数和 Decimal 等数值类型存在互操作性风险。(docs.python.org)

跨系统传递大整数时可以选择:

方案一:字符串

{"id": "9007199254740993"}

优点是不会被浮点消费者意外舍入;缺点是消费者必须按约定转换。

方案二:拆成高低位或十进制结构

{
  "unscaled": 1230,
  "scale": 2
}

表示:

1230 × 10^-2 = 12.30

方案三:限制 Schema 范围

如果接口规定整数必须位于安全范围内,可以在校验阶段拒绝超出范围的值,而不是让下游静默损坏。


6.4 金额的完整转换算例

假设 CSV 中有金额:

order_id,amount
1001,12.30
1002,0.10

错误做法:

import csv

total = 0.0

with open("orders.csv", encoding="utf-8", newline="") as fp:
    for row in csv.DictReader(fp):
        total += float(row["amount"])

print(total)

这种写法在金额累计时使用二进制浮点数,可能产生展示误差。

正确做法:

import csv
from decimal import Decimal

total = Decimal("0")

with open("orders.csv", encoding="utf-8", newline="") as fp:
    for row in csv.DictReader(fp):
        amount = Decimal(row["amount"])
        total += amount

print(total)

若业务要求金额始终保留两位小数,应在边界处量化:

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

amount = Decimal("12.305")
rounded = amount.quantize(CENT, rounding=ROUND_HALF_UP)

print(rounded)

输出:

12.31

这里的关键不是“使用某一个神奇类型”,而是明确规定:

  1. 输入格式;
  2. 输入单位;
  3. 内部表示;
  4. 舍入时机;
  5. 舍入模式;
  6. 输出格式。

七、复制与序列化:相似操作,完全不同语义

7.1 赋值不会复制对象

config = {
    "database": {
        "host": "localhost",
    }
}

other = config
other["database"]["host"] = "db.internal"

print(config["database"]["host"])

输出:

db.internal

other = config 只创建了新的引用绑定,没有创建新的字典对象。Python 官方文档明确区分了赋值和复制:赋值创建绑定,复制才创建新的复合对象。(docs.python.org)


7.2 浅拷贝只复制第一层

config = {
    "database": {
        "host": "localhost",
    }
}

other = config.copy()
other["database"]["host"] = "db.internal"

print(config["database"]["host"])

仍然输出:

db.internal

因为:

config       -> 外层 dict A
other        -> 外层 dict B
A["database"] ─┐
B["database"] ─┘ 同一个内层 dict

浅拷贝创建新的外层容器,但内部元素仍然共享引用。


7.3 深拷贝递归复制可变对象

from copy import deepcopy

config = {
    "database": {
        "host": "localhost",
    }
}

other = deepcopy(config)
other["database"]["host"] = "db.internal"

print(config["database"]["host"])
print(other["database"]["host"])

输出:

localhost
db.internal

deepcopy() 会递归复制内部对象,同时使用 memo 字典处理循环引用和已经复制过的对象。深拷贝也可能复制过多内容,破坏原本有意共享的对象关系。(docs.python.org)


7.4 JSON 往返不是通用深拷贝

有人会用下面的代码复制配置:

import json

copied = json.loads(json.dumps(original))

它只有在数据恰好属于 JSON 可表示子集时才可能有效,而且会改变类型:

  • tuple 变为 list
  • Decimal 默认无法序列化;
  • datetime 默认无法序列化;
  • 非字符串键变为字符串;
  • NaN 等特殊值可能产生非标准 JSON;
  • 自定义类的身份和行为丢失。

因此:

需要复制对象图 -> copy.deepcopy()
需要跨进程或跨系统交换 -> 明确设计序列化格式

不要把二者混为一谈。


7.5 pickle 与不可信输入

pickle 能够序列化比 JSON 更广泛的 Python 对象,但它是 Python 特定的二进制协议,不是通用数据交换格式。

最重要的安全边界是:

绝不能对不可信或可能被篡改的 pickle 数据执行 loads()Unpickler.load()

恶意 pickle 数据可能在反序列化过程中执行任意代码。Python 官方文档明确警告,只有在完全信任数据来源时才应使用 pickle;处理不可信数据时,JSON 等格式通常更适合。(docs.python.org)

比较:

能力 JSON pickle
可读性
跨语言
支持自定义 Python 对象 需要显式协议 较强
不可信输入安全性 不会因反序列化本身执行任意代码 不安全
适合 API 数据
适合 Python 内部缓存 有时 仅限可信边界

pickle 适合受控环境中的 Python 对象持久化,例如内部缓存或临时任务状态,但仍需处理版本兼容、类路径变化和数据完整性问题。


八、序列化后的 Schema 演化

Schema 不仅用于首次校验,还决定版本演化。

假设版本一:

{
  "name": "Alice",
  "age": 30
}

版本二新增可选字段:

{
  "name": "Alice",
  "age": 30,
  "timezone": "Asia/Shanghai"
}

如果旧消费者忽略未知字段,通常可以向后兼容;如果旧消费者拒绝未知字段,则新增字段会导致失败。

反过来,删除必填字段或改变字段类型,通常会破坏兼容性:

age: integer -> string

即使 "30" 看起来可以转换,也不代表所有消费者都会转换。是否允许宽松转换,应该是 Schema 和版本协议的明确决定,而不是解析器的偶然行为。

配置文件也存在同样问题:

timeout = 30

如果新版本把单位改成毫秒:

timeout = 30000

仅仅保留字段名不代表兼容。字段的单位、默认值和取值范围都属于 Schema 的语义。


九、错误处理:在正确层次捕获错误

不同阶段应捕获不同异常。

JSON 语法错误

import json

try:
    data = json.loads('{"name": "Alice",}')
except json.JSONDecodeError as exc:
    print({
        "message": exc.msg,
        "line": exc.lineno,
        "column": exc.colno,
        "position": exc.pos,
    })

JSONDecodeError 提供错误消息、文档位置、行号和列号,适合生成诊断信息。(docs.python.org)

CSV 结构错误

import csv

try:
    with open("input.csv", encoding="utf-8", newline="") as fp:
        for row in csv.reader(fp):
            print(row)
except (OSError, csv.Error) as exc:
    print(f"CSV 读取失败: {exc}")

但 CSV 解析成功不代表字段类型正确,因此 ValueErrorKeyError 等转换错误也应单独处理。

TOML 语法错误

import tomllib

try:
    config = tomllib.loads("port = ")
except tomllib.TOMLDecodeError as exc:
    print({
        "message": exc.msg,
        "line": exc.lineno,
        "column": exc.colno,
        "position": exc.pos,
    })

Python 3.14 的 TOMLDecodeError 公开了 msgdocposlinenocolno 等属性,便于精确定位配置错误。(docs.python.org)


十、输入大小与资源边界

“解析器没有代码执行漏洞”不等于“可以无限制解析外部输入”。

恶意或异常输入可能通过以下方式消耗资源:

  • 极大的 JSON 文本;
  • 极深的嵌套结构;
  • 超长字符串;
  • 超长整数;
  • 大量重复字段;
  • 复杂 TOML 文本。

Python 文档明确提醒,JSON 和 TOML 解析不应对不可信输入无限制开放,恶意输入可能消耗大量 CPU 和内存,应限制待解析数据的大小。(docs.python.org)

应用层可以先限制字节数:

from pathlib import Path
import json

MAX_SIZE = 2 * 1024 * 1024

path = Path("payload.json")

if path.stat().st_size > MAX_SIZE:
    raise ValueError("JSON 文件超过大小限制")

with path.open("rb") as fp:
    data = json.load(fp)

对于网络请求,还应在读取请求体时限制大小,而不是接收完整内容后再检查。对超大数组、批量 CSV 和 NDJSON,应采用流式处理或分片处理,避免一次性构造整个对象图。


十一、一个完整的标准库数据管线

下面的例子从 CSV 读取订单,使用 Decimal 计算金额,生成 JSON 输出:

import csv
import json
from decimal import Decimal
from pathlib import Path

INPUT = Path("orders.csv")
OUTPUT = Path("orders.json")
CENT = Decimal("0.01")


def read_orders(path: Path) -> list[dict]:
    orders = []

    with path.open("r", encoding="utf-8", newline="") as fp:
        reader = csv.DictReader(fp)

        required = {"order_id", "amount"}
        actual = set(reader.fieldnames or [])
        missing = required - actual

        if missing:
            raise ValueError(f"CSV 缺少字段: {sorted(missing)}")

        for line_number, row in enumerate(reader, start=2):
            try:
                order_id = int(row["order_id"])
                amount = Decimal(row["amount"])
            except (KeyError, ValueError):
                raise ValueError(
                    f"第 {line_number} 行类型错误: {row}"
                ) from None

            if order_id <= 0:
                raise ValueError(
                    f"第 {line_number} 行 order_id 必须为正数"
                )

            if amount < 0:
                raise ValueError(
                    f"第 {line_number} 行 amount 不能为负数"
                )

            amount = amount.quantize(CENT)

            orders.append({
                "order_id": order_id,
                "amount": amount,
            })

    return orders


def encode_json_value(value):
    if isinstance(value, Decimal):
        return format(value, "f")
    raise TypeError(f"无法编码类型: {type(value).__name__}")


orders = read_orders(INPUT)

with OUTPUT.open("w", encoding="utf-8") as fp:
    json.dump(
        orders,
        fp,
        ensure_ascii=False,
        indent=2,
        default=encode_json_value,
    )

假设输入:

order_id,amount
1001,12.30
1002,0.10

输出:

[
  {
    "order_id": 1001,
    "amount": "12.30"
  },
  {
    "order_id": 1002,
    "amount": "0.10"
  }
]

这里故意把 Decimal 输出为字符串,而不是 JSON 数字。原因是:

Decimal 的十进制精度语义
    -> JSON 数字
    -> 下游可能转为 IEEE 754 float

可能发生精度或尺度丢失。字符串需要下游按协议解释,但不会被普通 JSON 数字解析器静默转换为近似浮点数。

如果下游明确支持十进制数或固定精度协议,也可以使用数字输出;关键是 Schema 必须说明:

amount 是金额,单位为元,最多两位小数,传输形式为十进制字符串

十二、如何选择格式

选择 JSON

适用于:

  • HTTP API;
  • 嵌套对象;
  • 事件消息;
  • 跨语言数据交换;
  • 需要数组和对象组合的数据。

需要额外规定:

  • 数字范围;
  • 日期时间格式;
  • 空值语义;
  • 重复键处理;
  • 大整数传输形式;
  • Decimal 的传输形式。

选择 CSV

适用于:

  • 二维表格;
  • 电子表格交换;
  • 数据库导入导出;
  • 大量平面记录。

必须明确:

  • 分隔符;
  • 编码;
  • 是否有表头;
  • 引号规则;
  • 空值语义;
  • 字段类型;
  • 换行和跨行字段处理。

选择 TOML

适用于:

  • 人工维护的应用配置;
  • 项目元数据;
  • 开发工具配置;
  • 层次较浅的静态配置。

需要在解析后继续校验:

  • 端口和超时范围;
  • 路径;
  • 必填键;
  • 互斥选项;
  • 环境变量覆盖;
  • 配置版本。

选择 pickle

仅适用于:

  • 数据来源完全可信;
  • 读写双方都是受控的 Python 程序;
  • 需要保存复杂 Python 对象;
  • 已接受 Python 类路径和版本演化约束。

不适用于:

  • 用户上传文件;
  • HTTP 请求体;
  • 不可信缓存;
  • 跨语言接口;
  • 安全边界外的数据交换。

结语:真正的边界在转换点

结构化数据处理可以归纳为一条转换链:

文本
  -> 格式解析
  -> 基础 Python 对象
  -> Schema 校验
  -> 领域模型
  -> 业务计算
  -> 序列化输出
  -> 另一个系统重新解释

每个箭头都可能改变语义:

  • JSON 对象键被统一为字符串;
  • CSV 字段默认只是字符串;
  • TOML 本地时间可能没有时区;
  • float 可能无法精确表示十进制小数;
  • Python 大整数可能超出下游安全范围;
  • JSON 往返可能改变元组和自定义对象;
  • pickle 反序列化可能执行恶意代码;
  • Schema 校验通过也不代表满足数据库级业务约束。

因此,可靠的数据管线不是“选一个最好的格式”,而是明确每一个边界:

格式负责表示;
解析器负责还原基础结构;
Schema 负责验证契约;
领域模型负责承载语义;
Decimal 或整数单位负责控制精度;
序列化协议负责跨边界保持含义;
安全策略负责限制不可信输入。

当这些职责被分开,JSON、CSV、TOML、复制、pickle 和 Pydantic 就不再是互相竞争的工具,而是数据生命周期中不同位置的组件。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。