Python CSV 处理:正确应对逗号、换行与 UTF-8 BOM
CSV 不是按逗号切开的文本
从数据库导出报表、接收供应商清单,或者把接口数据交给运营人员时,CSV 是常见的交换格式。但它的简单外观容易产生误导:对每行调用 split(‘,’),遇到字段内部的逗号就会错列;先用 splitlines() 拆行,遇到带换行的备注又会把一条记录拆成两条。
正确做法是让 CSV 解析器处理引号和记录边界,再在业务层验证字段。本文用 Python 标准库 csv 演示一次完整的写入与读取,并说明编码、空值和电子表格打开时需要区分的问题。示例是本地功能验证,不涉及外部数据或性能基准。
一、先明确三个约定
第一,编码不是 CSV 格式本身规定的。数据双方应约定 UTF-8 等编码,不能因为文件后缀是 .csv 就假定编码正确。
第二,分隔符也需要约定。本文使用逗号;如果上游使用分号,应显式指定 delimiter=‘;’,不要靠替换文本转换格式。
第三,列名和空值语义属于业务协议。CSV 不会自动保留整数、日期和空值类型,读取到的普通字段通常是字符串。写出 None 时,csv.writer 会将它转换成空字符串,这种转换不可逆;若需要区分“缺失”与“空字符串”,应另行约定表示方法或选择其他格式。
二、最小可运行的往返测试
以下示例可以直接作为 Python 3 脚本运行。内存文本流用于避免创建临时业务文件,文件读写的对应方式见下一节。
import csv
import io
FIELDS = ["id", "name", "note"]
def encode_csv(rows):
stream = io.StringIO(newline="")
writer = csv.DictWriter(stream, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(rows)
return stream.getvalue()
def decode_csv(text):
stream = io.StringIO(text, newline="")
reader = csv.DictReader(stream)
if reader.fieldnames != FIELDS:
raise ValueError("unexpected CSV header")
result = []
for record_number, row in enumerate(reader, start=1):
if None in row or any(value is None for value in row.values()):
raise ValueError(f"wrong column count at record {record_number}")
result.append(row)
return result
def main():
rows = [
{"id": "001", "name": "Alice, Bob", "note": 'say "hello"'},
{"id": "002", "name": "Carol", "note": "line one\nline two"},
{"id": "003", "name": "", "note": ""},
]
text = encode_csv(rows)
assert decode_csv(text) == rows
print("quoted_fields_round_trip:", True)
data_with_bom = text.encode("utf-8-sig")
restored = data_with_bom.decode("utf-8-sig")
assert decode_csv(restored) == rows
print("utf8_bom_round_trip:", True)
for bad in ["id,name,note\r\n1,A\r\n",
"id,name,note\r\n1,A,B,extra\r\n"]:
try:
decode_csv(bad)
except ValueError:
pass
else:
raise AssertionError("invalid column count was accepted")
print("wrong_column_count_rejected:", True)
if __name__ == "__main__":
main()
运行时,三个检查项都应输出 True。第一项同时覆盖含逗号、双引号、字段内换行和空字符串的数据;第二项验证带 UTF-8 BOM 的字节可以正常恢复;第三项验证少列和多列记录不会被静默接受。
id 使用字符串,是为了保留 001 这样的前导零。程序若主动调用 int(),前导零就会消失;这不是 CSV 解析器丢失数据。
三、真实文件为什么使用 newline=‘’
实际写入时,可以使用如下方式:
with open("report.csv", "w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(rows)
with open("report.csv", "r", encoding="utf-8-sig", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
print(row["id"], row["name"])
此片段中的 FIELDS 和 rows 沿用前文定义。newline=‘’ 将换行处理交给 csv 模块,避免文本层换行转换与 CSV 自身的换行处理叠加,在 Windows 等环境尤其值得注意。不要为了消除所谓“多余换行”,在生成完成后对整个文件做字符串替换;那可能改变字段中的真实换行。
读取采用 utf-8-sig,可以消费开头的 UTF-8 BOM;没有 BOM 的普通 UTF-8 文件也能读取。如果上游带 BOM 却用普通 utf-8 解码,第一列名可能带有不可见的 U+FEFF,导致 row[‘id’] 查找失败。utf-8-sig 不是通用编码检测器,不能据此读取任意 GBK 文件。
四、列数检查和格式严格性不是一回事
DictReader 对少列记录默认补 None,多列记录默认把多出来的字段放到键 None 下。因此示例显式检查这两种情况,而空字符串仍被允许。
示例中的 record_number 是数据记录序号,不是文件物理行号:一条带换行的记录可能跨越多行。需要定位源文件时可结合 reader.line_num,但它表示解析器已读取的物理行数,也不总是记录起始行。
这段代码只演示表头与列数验证,不是任意畸形输入的完整校验器。对不可信输入,还应根据业务设置文件大小、字段长度、记录数量限制,以及必要的数值和日期校验。若需要严格的引号语法处理,可考虑 strict=True 并处理 csv.Error;它仍不替代业务字段验证。
五、能解析不等于电子表格打开后完全一样
CSV 引号用于语法转义,不是数据类型声明。即使正确写出 001,电子表格直接打开时仍可能推断为数字;如果需要保留文本类型,应使用导入向导指定列类型,或选择能明确设置单元格类型的工作簿格式。
同样,来自不可信用户的字段若以等号等特殊字符开头,电子表格软件可能将它解释为公式。给字段加 CSV 双引号不等于消除了公式注入风险。面向电子表格的导出应制定单独的文本化策略,并在目标软件上验证;不要无说明地修改机器间交换的原始值。
总结起来,CSV 的可靠处理有两层:语法层让 csv 模块负责分隔符、引号和换行;协议层明确编码、列结构、空值与类型。把两层分清,才能避免“文件打开没报错,但数据已经变了”的隐蔽问题。
- 点赞
- 收藏
- 关注作者
评论(0)