Python CSV 读写:换行、BOM 与字段类型的三个边界

举报
yd_254857454 发表于 2026/10/31 13:30:59 2026/10/31
【摘要】 CSV 能解析成功,不代表字段内容和业务类型正确。本文用 Python 标准库验证引号内换行、UTF-8 BOM 和字符串转换三个边界,给出可运行示例,并显式拒绝列数不匹配和非法数量。

一、先让 CSV 解析器管理换行

配置导入、数据交换和批量任务经常使用 CSV。一个看起来简单的表格,至少经过三层处理:字节按编码变成文本,CSV 解析器识别记录与字段,业务代码再把字段解释为编号或数量。三层的职责混在一起,问题往往表现为“文件能打开,结果却不对”。

例如备注是 第一行\r\n第二行,含"双引号"。其中既有逗号,也有双引号和字段内部换行;它仍然只是一个单元格。不能直接用 split(",") 拆字段,也不能把每个物理文本行都当作一条完整记录。应让 csv.writer 负责引用和转义,让 csv.reader 负责还原。

用文件对象读写 CSV 时,Python 文档建议显式传入 newline=""。它让文本层不转换换行,把处理交给 CSV 模块。若读取时使用默认的通用换行转换,字段内部的 CRLF 可能变成 LF。写入时,文本层与 CSV 写入器同时处理换行,也可能在部分平台产生多余回车。省略参数并非每次都会报错,因此只用一个没有特殊字符的样本很难发现问题。

下面的实现读取固定格式的物料记录:编号保留为字符串,数量转换为非负整数,备注保留原文。它要求表头和列顺序完全一致,这是本例的输入约定,并非 CSV 格式对所有文件的要求。

import codecs
import csv
from pathlib import Path
from tempfile import TemporaryDirectory

FIELDS = ["item_id", "quantity", "note"]


def load_items(path):
    result = []
    with open(path, encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream, restkey="__extra__", strict=True)
        if reader.fieldnames != FIELDS:
            raise ValueError("unexpected header or column order")
        for row in reader:
            if "__extra__" in row or any(row[key] is None for key in FIELDS):
                raise ValueError("column count mismatch")
            try:
                quantity = int(row["quantity"])
            except ValueError as error:
                raise ValueError("quantity must be an integer") from error
            if quantity < 0:
                raise ValueError("quantity must be nonnegative")
            result.append({
                "item_id": row["item_id"],
                "quantity": quantity,
                "note": row["note"],
            })
    return result

二、编码正确,表头也可能多一个字符

UTF-8 BOM 是文件开头的三个字节 EF BB BF。普通 utf-8 解码不会自动移除它,于是 CSV 首列表头可能成为 "\ufeffitem_id"。界面上看起来仍像 item_id,代码按正常键名取值却会失败。排查这种问题,可以查看表头的 repr(),而不是只靠视觉比较。

输入已经约定为 UTF-8、且允许文件带 BOM 时,encoding="utf-8-sig" 会在解码时跳过起始 BOM;没有 BOM 的 UTF-8 也能读取。它不会删除文本中间所有 U+FEFF,更不能自动识别 GBK 等其他编码。拿到不同系统导出的文件,应先明确编码契约,不要反复更换编码直到“不报错”为止。

写文件时,utf-8-sig 会加入 UTF-8 签名。是否需要写 BOM 应由接收方约定决定,不能把“加 BOM”当作所有导出的统一答案。下面为了演示表头差异,特意写出带 BOM 的文件,并明确指定记录结束符为 CRLF;这不是对任何表格软件兼容性的保证。

将下面的代码接在前一个代码块后运行。演示只使用独立临时目录,不会覆盖当前目录的业务文件。

with TemporaryDirectory() as directory:
    path = Path(directory) / "items.csv"
    note = '第一行\r\n第二行,含"双引号"'
    with open(path, "w", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(
            stream, fieldnames=FIELDS, lineterminator="\r\n"
        )
        writer.writeheader()
        writer.writerow({"item_id": "0012", "quantity": "03", "note": note})

    assert path.read_bytes().startswith(codecs.BOM_UTF8)
    with open(path, encoding="utf-8", newline="") as stream:
        assert csv.DictReader(stream).fieldnames[0] == "\ufeffitem_id"

    with open(path, encoding="utf-8-sig", newline="") as stream:
        raw = next(csv.DictReader(stream))
    assert raw["quantity"] == "03"
    assert raw["note"] == note

    items = load_items(path)
    assert items == [{"item_id": "0012", "quantity": 3, "note": note}]
    print("BOM、内嵌换行、逗号引号和显式类型转换验证通过")

三、解析出字段以后,再讨论类型

在本例采用的默认读取选项下,实际解析出的字段是字符串。因此 "03" 不会自动变成整数,"0012" 也不会自动丢掉前导零。CSV 本身没有为这两列保存“编号”和“数量”的业务类型;同样一段字符,应该保留还是转换,需要由调用方决定。QUOTE_NONNUMERIC 等选项会改变部分转换行为,使用时也要明确约定,不能用来代替列级校验。

DictReader 对列数不匹配的默认行为也容易被忽略:多出的字段会作为列表放到 restkey 指定的键中,缺少的字段则用 restval 补齐,默认是 None。本例用保留键 __extra__ 检测多列,再检查缺列;表头已经严格验证,所以这个保留键不会与合法业务列冲突。不能因为得到一个字典,就认为每条记录的结构已经合格。

存在但为空的字段 "" 和缺列填入的 None 也不同。本例允许备注为空,但不允许备注这一列缺失;空数量会在 int() 转换时被拒绝。示例并未校验编号不能为空,真实导入若有此要求,应另加规则。把空值策略明确写出来,比统一调用 str() 把各种值揉成文本更便于追踪数据来源。

数量转换成功后,还要检查范围。int("-1") 合法,却不符合本例的非负要求。这里采用 Python 的整数解析规则,它也接受首尾空白、符号和某些下划线写法;如果接口只接受 ASCII 十进制数字,应在转换前增加更严格的文本校验。strict=True 关注 CSV 格式错误,不会替你判断数量、必填项或重复业务编号。

反方向导出时,空值含义也需要约定。CSV 写入器会把 None 写成空字符串,普通读取无法判断它原来究竟是空文本还是缺失值。如果业务必须区分,可以增加明确的状态列,或在接口协议中约定缺失值表示法,并处理该表示法与真实文本冲突的情况。不能指望再读取一次,就自动恢复写入前的所有 Python 类型。

同样,不建议让接收方从文件名或文件内容里猜分隔符和列含义。交接时写清编码是否允许 BOM、分隔符、引用规则、表头名称、空值策略和字段范围。对于已有稳定接口,严格拒绝结构变化通常比悄悄忽略新增列更易发现上游改动;需要兼容扩展时,也应在版本约定中明确哪些列可忽略。

四、验证结果与适用范围

本文示例已在 macOS、Python 3.9.6 本地运行。逗号和双引号往返后内容完整,newline="" 保留了备注中的 CRLF,编号 0012 保持字符串,数量从字符串 03 显式变为整数 3。另行使用默认通用换行读取同一文件,确认内嵌 CRLF 被规范化为 LF;还验证了多列、少列、非整数数量、负数量、重复表头五条拒绝路径。

这些验证没有涉及 Windows 实机或具体表格软件。示例也没有尝试猜测分隔符、自动纠正坏记录,或把缺失值默认为零。调用方需要保留异常和文件上下文;要定位具体数据时,可同时记录 reader.line_num,但它表示解析器已读的物理行数,不能直接当成记录序号,尤其是在字段含换行时。

排错也可以沿三层分别进行:编码失败先确认字节来源,CSV 格式失败检查分隔与引用约定,转换或范围失败再定位业务字段。不要把这些异常一律捕获后继续导入,否则使用者看到的可能是一份缺行但被标为成功的结果。若选择允许部分成功,应同时交付成功数量与失败记录,让调用者决定是否接受该批次。

示例先收集结果再返回,适合小型导入。对于大文件,可以逐条验证、分批处理,但还要明确后半部分失败时,前面已经写入数据库的记录如何恢复。编码、CSV 结构和业务类型分别正确,才是一个可以继续交给下游的输入;成功读出文本只是其中一步。

参考:Python 标准库文档中 csv.reader()、csv.DictReader()、csv.DictWriter()、open() 的换行参数、encodings.utf_8_sig 与 int() 对应章节。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。