ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

polars读取CSV报错?根因藏在UTF-8 BOM与注释行里

polars读取CSV报错?根因藏在UTF-8 BOM与注释行里

上周一个同事跑过来找我,说用polars读一个“vertex文件”的时候报错,报错信息他贴过来我看了三遍都没看懂。文件在pandas里读得好好的,打开Excel也正常,可polars就是死活不认。我第一反应是路径问题,毕竟Windows下中文路径、反斜杠这类破事见得多了。但查了一圈发现根本不是路径的事,真正的原因藏在文件的前几个字节里。为了让大家少走弯路,我把完整排查过程和最终解法整理成这篇博文。

1. 离奇错误现场:同一个文件,两个版本的报错

1.1 vertex格式文件到底是什么

先说清楚“vertex文件”是什么。这个说法并不是某个公开标准格式,而是图数据分析里常见的命名习惯:从图数据库或图计算框架里导出顶点数据时,文件常常就叫vertex.csv或vertices.vertex。因为这类文件通常在内部当作图数据使用,扩展名并不统一,有的甚至完全没有扩展名。我们现在处理的项目里,vertex.vertex就是一个用CSV编码的图顶点表,内容大概是这样的:

# vertex-export-v2 # legend: id,label,lat,lng,props id,label,lat,lng,props v001,person,37.421,-122.084,"{""age"":30, ""verified"":true}" v002,place,0.0,0.0,"{}"

前两行以#开头,是导出工具自动生成的说明信息。第三行才是真正的列名,后面是数据行。props列里存的是JSON字符串,表示顶点的动态属性。整个文件是UTF-8编码,并且在Windows平台上被Excel另存过一次,所以文件头部带了BOM(Byte Order Mark,也就是0xEF 0xBB 0xBF三个字节)。

我们选择polars而不是pandas,原因很简单:顶点表动辄几千万行,pandas载入内存后经常把内存吃满,而polars支持惰性执行和流式处理,scan_csv可以边读边算,内存压力小得多。这块数据的量级已经不适合用pandas硬扛了。

1.2 第一次报错:expected 1 fields, found 6

第一次读取,我同事用的是最自然的写法:

import polars as pl df = pl.read_csv("vertex.vertex")

结果polars直接抛了一个看起来莫名其妙的异常:

ComputeError: found more fields than expected: expected 1 fields, found 6

“预期1个字段,实际找到6个字段”?这个文件明明有6列,怎么可能预期1列?我第一反应是分隔符问题,因为有些CSV文件是用;分隔的,但这里明明看起来是逗号。后来我意识到,polars把文件的第一行当成了表头,而第一行是# vertex-export-v2,这一整行只有一个字段(因为里面没有任何逗号)。于是polars认为这个表只有1列,接着读到第二行# legend: id,...发现有6个逗号、7个字段,跟预期的1个字段对不上,所以直接报错。

这个报错本身的逻辑并没有错,错的是我们没告诉polars“文件有注释行”。

1.3 第二次报错:列名明明存在却ColumnNotFound

我加上注释参数,觉得应该稳了:

df = pl.read_csv("vertex.vertex", comment_prefix="#")

这次不再报字段数错误了。polars跳过了所有#开头的行,从第三行id,label,lat,lng,props读取表头。看起来一切正常,但紧接着操作就翻车了:

print(df.columns) # 打印出来是 ['id', 'label', 'lat', 'lng', 'props'] print(df["id"].head())

结果:

ColumnNotFoundError: id

这简直离谱。df.columns里明明有id这一列,用df["id"]去访问却报“找不到列”。我一度怀疑polars是不是把列名做了某种特殊处理,或者存在大小写、编码差异。用df["lat"]、df["props"]试了也一样,全都报找不到。但打印出来的列名列列在目,好像一切正常一样。

这个错误真正让人恼火的地方在于:你看不到任何异常字符,列名打印出来干干净净,可程序就是访问不到。如果不是亲历,很难想到问题出在一个肉眼根本看不见的字符上。

2. 排查链路:不迷信报错,从文件真实字节开始查

2.1 先用十六进制看文件头

遇到这种表面矛盾,最有效的办法就是丢掉“肉眼判断”,直接看文件的原始字节。我在终端里用十六进制查看文件开头的几个字节:

$ xxd vertex.vertex | head -n 3 00000000: efbb bf23 2076 6572 7465 782d 6578 706f ...# vertex-expo 00000010: 7274 2d76 320a 2320 6c65 6765 6e64 3a20 rt-v2.# legend: 00000020: 6964 2c6c 6162 656c 2c6c 6174 2c6c 6e67 id,label,lat,lng

看到efbb bf了吗?这就是UTF-8的BOM标记。也就是说,文件开头并不是直接以#开始,而是先有三个不可见的字节EF BB BF,然后才是# vertex-export-v2。

polars在读取CSV时,如果没有显式处理BOM,会把EF BB BF当作普通字符拼到第一个字段的开头。于是第三行真正的表头id,...,props里,第一列名就变成了\ufeffid,而不是id。

2.2 用Python脚本验证三件事

我用一个简单脚本确认了三个关键点:

with open("vertex.vertex", "rb") as f: raw = f.read(32) print(raw) with open("vertex.vertex", encoding="utf8") as f: lines = f.readlines() print(repr(lines[0])) print(repr(lines[2]))

输出结果:

b'\xef\xbb\xbf# vertex-export-v2\n' '# vertex-export-v2\n' 'id,label,lat,lng,props\n'

第一行确实带着BOM。第三行的表头看起来是id开头,但结合polars读出来的列名,可以断定BOM被polars合并到了第一列列名上。再进一步验证:

df = pl.read_csv("vertex.vertex", comment_prefix="#") print(repr(df.columns[0]))

输出:

'\ufeffid'

真相大白。屏幕上打印df.columns时,\ufeff是零宽不换行空格,终端里根本看不见,所以看起来就是id。但Python的字典匹配是严格字符匹配,带\ufeff和没带\ufeff是两个完全不同的字符串。

2.3 pandas为什么没炸

同事之前说“pandas里读得好好的”,这也是事实。pandas的read_csv底层用C引擎,对于UTF-8 BOM有自动剥离处理,所以它在读表头时默认把BOM去掉了。而polars在读取CSV时,至少在我当时使用的版本里,没有自动剥离BOM,导致这个坑暴露出来。

这不是说pandas比polars好,只是两边的默认行为不同。在跨库切换时,这种隐含差异最容易被忽略,也最容易造成“离奇错误”。

3. 根因拆解:BOM、注释结构与polars的CSV解析三条线交汇

3.1 注释行为什么会导致字段数爆炸

从解析原理上讲,polars读取CSV时会先扫描文件头部,用表头行确定整张表的schema(列数、列名、数据类型)。如果没有指定has_header=False,它默认把第一个非空行当作表头。

在原始文件里,第一个非空行是# vertex-export-v2。这一行没有任何逗号,polars据此推断整个表只有1列。接下来扫描到第二行# legend: ...,这行里有6个逗号,polars发现字段数严重超过预期,于是抛出“found more fields than expected”。

这个错误会给人很强的误导性,因为它看起来像是数据类型或分隔符问题。但实际上根子是“文件头部有垃圾行”这个非常朴素的问题。类似的情况还有:某些导出工具会在CSV开头加版权声明、导出时间、列含义说明等。如果不告诉解析器跳过这些行,任何严格按第一行建立schema的库都会出问题。

3.2 BOM是怎么混进列名的

BOM是Unicode编码规范里用来标识字节序的字符,在UTF-8中固定编码为EF BB BF。很多Windows程序(尤其老旧的导出工具、Excel另存为等)为了兼容性,会在保存UTF-8文件时自动加上这个标记。

对大多数编辑器来说,BOM是隐藏的,你打开文件根本看不到它。对pandas来说,它会在文本读取阶段剥掉BOM。对polars来说,它做的是原始字节到UTF-8字符串的映射,没有额外过滤,于是BOM字符就保留在了第一个字段的开头。

当文件第一行是表头时,BOM就粘在第一个列名上。你以为列名是id,实际是\ufeffid。这解释了一切看似矛盾的现象:df.columns里有一个看不见前缀的列名,df["id"]自然匹配不上。

3.3 为什么这类问题最容易在项目交接时爆发

这次的vertex文件不是我们内部生成的,是从另一个图数据处理模块导出的。对方在Windows环境用Excel重新保存过文件,于是“注释行 + BOM + 自定义扩展名”三个因素叠加在一起。pandas的老用户习惯了它自动处理BOM,根本想不到还要关注文件字节;而polars的默认行为更“较真”,你给它什么字节它就读什么字节。

所以这类问题最容易出现在跨工具、跨团队的数据流转场景。文件在生成端看起来正常,在传统工具里读起来正常,唯独换了一个更严谨的解析器时触发。

4. 修复代码与通用读取函数:以后不再踩坑

4.1 直接解法:配合comment_prefix并清理列名

如果你只是想把当前这个文件读进来,最直接的做法是两步:

df = pl.read_csv("vertex.vertex", comment_prefix="#") df = df.rename({df.columns[0]: df.columns[0].lstrip("\ufeff")}) print(df.head())

第一行跳过注释,让表头正确落到id,label,...上;第二行把第一个列名中的BOM字符去掉。lstrip("\ufeff")是安全的,因为真正的列名id前面不会再有其他需要保留的字符。

改成这一步之后,df["id"]、df["props"]都正常了。

4.2 更稳的兜底方案:先剥BOM再读

如果你的文件来源不固定,根本不知道有没有BOM,也不想每次手动修列名,可以在读取前用Python标准库做一个预处理:

from pathlib import Path path = Path("vertex.vertex") raw = path.read_bytes() if raw.startswith(b"\xef\xbb\xbf"): raw = raw[len(b"\xef\xbb\xbf"):] clean_path = path.with_suffix(".vertex.clean") clean_path.write_bytes(raw) df = pl.read_csv(clean_path, comment_prefix="#")

先把文件头三个字节剥掉,再交给polars。这样polars看到的第一个字符就是#,comment_prefix能正确生效,列名也会干干净净。

我后来查了一下,polars的read_csv参数里有encoding,但没有类似utf8-sig这种专门处理BOM的编码名。与其依赖库去处理,不如我们自己动手剥BOM,一劳永逸。

4.3 封装一个通用的read_vertex_file函数

处理一个文件容易,处理一堆文件就难了。我最后把逻辑封装成一个函数,以后项目里任何vertex文件都走这个入口:

import polars as pl from pathlib import Path def read_vertex_file(path: str | Path, comment_prefix: str = "#") -> pl.DataFrame: p = Path(path) raw = p.read_bytes() # 去掉UTF-8 BOM if raw.startswith(b"\xef\xbb\xbf"): raw = raw[len(b"\xef\xbb\xbf"):] # 写回临时文件再读取 tmp = p.with_suffix(".vertex.clean") tmp.write_bytes(raw) df = pl.read_csv(tmp, comment_prefix=comment_prefix) # 清洗所有列名,去除不可见控制字符和首尾空白 clean_cols = {col: col.strip().lstrip("\ufeff") for col in df.columns} df = df.rename(clean_cols) # 顺手清理临时文件 tmp.unlink(missing_ok=True) return df df = read_vertex_file("vertex.vertex")

这个函数做了三件事:剥BOM、跳过注释、清洗列名。对于项目里那些来自不同系统的vertex文件,基本都能一把梭读进来。当然,如果文件里还有其他奇怪字符,你可以在clean_cols的字典推导式里继续加清洗规则。

4.4 进阶:把props列解析成结构化数据

顶点表的props列是JSON字符串。polars读进来后它是String类型,如果你只是想做简单筛选,倒无所谓。但如果要按属性字段统计,最好把JSON解码成结构化数据:

df = df.with_columns(pl.col("props").str.json_decode()) print(df.head()) shape: (2, 5) id label lat lng props str str f64 f64 struct[2] v001 person 37.421 -122.084 {"age":30,"verified":true} v002 place 0.0 0.0 {}

json_decode()会把JSON字符串转成polars的Struct类型,之后就能用df["props"].struct.field("age")直接取属性。对于几千万行的数据,这样做效率很高。

需要注意的是,如果props列里存在空字符串或非法JSON,json_decode会报错。建议先做一次类型检查或用pl.col("props").str.json_decode(strict=False),把无法解析的行变成null,后续再统一处理。我当时就遇到过一个文件里混了单引号JSON,直接解码就翻车,加上strict=False才稳住。

5. 复盘:这类“离奇错误”的共性与处理心法

5.1 数据文件头部信息障眼法

CSV文件格式本身非常宽松,没有强制的头结构约束。导出工具为了易读性,经常在真正的表头前加注释行、说明行甚至空行。任何解析库在“第一行即表头”的默认假设下都会踩坑。

以后遇到“expected N fields, found M fields”这种错误,先别急着怀疑分隔符或类型推断,而是打开文件看看前几行到底长什么样。用xxd或repr查看是最可靠的,肉眼不可信。

5.2 不可见字符是ColumnNotFound的高频元凶

ColumnNotFoundError本身很好排查,难的是“列名看起来完全正常”。一旦出现这种矛盾,就要怀疑不可见字符:BOM、零宽空格、全角空格、\r回车符等。排查时一定用repr(df.columns)而不是print(df.columns),前者能把隐藏字符显示成'\ufeffid',后者直接给你一个假象。

另外,如果从外部系统导出的列名里有尾部空格,也会导致同样的报错。通用清洗列名并统一去掉首尾空格,是个低成本高收益的习惯。

5.3 一份快速自检清单

我在项目文档里加了一张表,以后同事再遇到类似问题可以先自查:

现象可能原因验证手段解决方案
found more fields than expected文件头有注释行/空行,被当成表头查看文件前几行设置comment_prefix或skip_rows
ColumnNotFoundError但列名看起来一样列名携带BOM、空格、控制字符repr(df.columns)清洗列名(strip、lstrip('\ufeff'))
CSV解析乱码编码不是UTF-8检查原始字节用encoding='gbk'等正确编码或先转码
JSON字段无法解析JSON格式不规范抽查原始数据用json_decode(strict=False),先转成null再处理

这三类问题在pandas用户切换到polars时特别容易出现,因为两边对编码和注释的默认处理策略不一致。换成polars之后,记得把“文件原始字节”这个习惯捡起来。

这次踩坑让我最大的体会是:报错信息只是线索,不是答案。尤其是surface上看起来前后矛盾的报错,几乎都意味着某个环节有我们没看到的隐藏字符或隐藏行。多花两分钟看原始字节,比对着报错猜半小时强得多。如果你也在项目里遇到过类似的“离奇错误”,希望这篇复盘能帮你少走这一步。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进