Python 读 Excel 慢到想砸键盘:我把 38 万行台账从 4 分钟压到 6 秒

🔑 关键词:python 读取excel慢, python-calamine, pandas read_excel, openpyxl read_only, duckdb 读excel

📖 摘要:同一个 38.7 万行的 xlsx,pandas 默认读 252 秒吃 3.1G 内存,换 calamine 引擎后 6.8 秒。这篇把六种读法的实测数据、代码、内存占用和适用边界都摆出来,也说了什么时候你根本不该折腾。

先说个事,我上周差点把笔记本摔了

图片

朋友是做服装批发的,甩给我一个 38.7 万行的销售台账 xlsx,让我"帮忙按门店和月份汇总一下"。我心想这不就是 groupby 的事,打开 Jupyter 写了三行:

import pandas as pd
df = pd.read_excel("sales.xlsx")
df.groupby(["门店", "月份"])["金额"].sum()

然后我就去泡面了。回来一看——4 分 12 秒,还没读完。风扇呼呼叫,内存吃到 3.1GB(我这是 M1 的 8G 版本,卡得鼠标都在转圈)。

那一刻我是真有点破防。因为这文件本身才 21MB。21MB 的文件,读四分钟。

后来我把这事捋了一遍,发现坑不在 pandas,也不在你电脑,在于 read_excel 这条路本身就是"翻译层套翻译层":底层是 openpyxl 或者 calamine,把每个单元格变成一个 Python 对象,pandas 再把这些对象拼成二维表,中间还要猜每一列是什么类型。38 万行 × 9 列,就是三百多万个单元格,三百多万个 Python 对象的创建和销毁。慢是应该的。

顺便说一句,.xlsx 本身是个 zip 包,解开来里面是一堆 XML。Excel 自己打开大文件也慢,你只是没注意过而已。

我把手上能试的方案都跑了一遍

图片

测试环境:M1 / 8GB 内存 / macOS 14.5 / Python 3.12.3。数据是我用一个脚本生成的模拟台账,9 列——订单号、日期、门店、商品编码、数量、单价、金额、业务员、备注。每个方案跑三次取中位数,第一次预热不算。

方案 38.7万行耗时 峰值内存 装起来麻不麻烦
pandas.read_excel(默认 openpyxl) 252s 3.1GB 最省事
pandas.read_excel(engine="calamine") 6.8s 480MB 要装 python-calamine
openpyxl read_only 流式迭代 41s 210MB 一般
python-calamine 直接读 4.2s 390MB 一般
先另存 CSV 再 read_csv 3.1s(不含另存时间) 300MB 要手动操作
DuckDB read_xlsx 9.5s 520MB 要装 duckdb

那 6.8 秒和 252 秒的差距,说白了就是一个 Rust 写的读取器(calamine)对比一个纯 Python 的读取器(openpyxl)。没别的玄学,也不是你 CPU 不行。

pandas.read_excel 从 pandas 2.2 开始支持 engine="calamine",你只要做两件事:

pip install python-calamine
import pandas as pd
df = pd.read_excel("sales.xlsx", engine="calamine")

就这一行参数。我后来把这个发给我朋友,他回了我一串问号。

图片

还有个细节:calamine 对日期列的处理跟 openpyxl 略有不同,如果你的日期列读出来变成了 datetime.datetime 但你原来期望是字符串,或者反过来,别慌,那是引擎差异,加个 dtype 或者读完之后 astype 一下就行。我第一次踩的时候还以为文件坏了。

但有些场景,换库根本没用

这里我要说一个跟网上大部分教程不太一样的观点:如果你的文件只读一次、就几千行,上面那套方案一个字都别信,别折腾。

你自己算一下:3000 行的表,read_excel 大概 0.4 秒,calamine 0.05 秒。你省下的 0.35 秒,不够你打 pip install python-calamine 这一串字母的时间。而且多一个依赖,团队里多一个人要装,CI 里多一个可能哪天出问题的坑。我就见过有人为了给一个 200 行的配置表提速,把依赖树搞复杂了,后来那个包停止维护,升 Python 版本的时候直接炸掉。

真正值得动手的情况大概是这几种:

  • 文件超过 5 万行,或者这个读取动作每天要跑很多次
  • 内存吃紧,8G 或更小的机器,不想让程序吃到 swap
  • 你在做定时任务,跑一次晚 3 分钟就会撞上别的任务
  • 文件是别人发你的,列数不固定,你想先探一下结构再决定怎么处理

反过来,如果文件是老的 .xls 格式、里面有合并单元格、有公式引用了别的 sheet、有数据透视表——这些情况 calamine 和 openpyxl 都可能给你"惊喜"。最稳的做法是先用 Excel 或者 LibreOffice 另存成干净的 .xlsx,把格式问题在源头解决掉,再谈提速。

内存真的不够,就别想着一次读完

图片

上面那几种方式本质都是"一次性读进内存"。38 万行 × 9 列大概 500MB 上下,还扛得住。但我手上有过一个 200 万行的表,直接把机器干到 swap,光标卡住不动。

这时候正确姿势是流式读,用 openpyxl 的只读模式:

from openpyxl import load_workbook

wb = load_workbook("big.xlsx", read_only=True, data_only=True)
ws = wb.active

total = {}
for i, row in enumerate(ws.iter_rows(values_only=True)):
    if i == 0:
        continue  # 跳过表头
    store, month, amount = row[2], row[1].strftime("%Y-%m"), row[6]
    total[(store, month)] = total.get((store, month), 0) + amount

wb.close()

几个参数我说清楚,别抄错:

图片

  • read_only=True:不把整个 sheet 建成对象树,边读边释放,内存能压到原来的十分之一左右
  • data_only=True:读到公式格时给你缓存的计算结果,而不是 =SUM(A1:A9) 这个字符串
  • values_only=True:直接吐 Python 的 int / str / datetime,省掉一层 cell.value
  • 结束一定记得 wb.close(),不然那个 zip 句柄不释放,Windows 上会把文件锁住,下次写回直接报错

代价也很明确:只读模式下你没法写回,也没法随机访问某个单元格,只能一行一行往下走。而且它依然比 calamine 慢——我测的 38 万行是 41 秒。但内存只要 210MB。有时候你就是要拿时间换空间。

还有一个很少人提的角度

如果你只是想把 Excel 当数据源做查询,完全可以绕开"读进内存"这件事,让查询引擎自己去分块读:

import duckdb
con = duckdb.connect()
con.execute("INSTALL excel; LOAD excel;")
df = con.execute(
    "SELECT 门店, sum(金额) AS 总金额 FROM read_xlsx('sales.xlsx') GROUP BY 门店"
).df()

DuckDB 会把聚合下推到读取阶段,我只要汇总结果的时候,它连中间那张大表都不会完整建出来。同样的逻辑你要是先用 pandas 读全表再 groupby,内存曲线上差一大截,尤其是列特别宽的表。

不过这招有个前提:你的文件得规整,表头在第一行,没有乱七八糟的合并单元格和空行。DuckDB 不认识"这一行其实是标题"这种事,它会老老实实当成数据。我试过一份带两层表头的报表,读出来的列名是 Unnamed: 3 这种,挺尴尬的。

图片

最后

我现在的默认写法是这样,你可以直接抄:

import pandas as pd

def read_sheet(path, sheet=0, **kw):
    try:
        return pd.read_excel(path, sheet_name=sheet, engine="calamine", **kw)
    except ImportError:
        return pd.read_excel(path, sheet_name=sheet, **kw)

能快就快,装不上就退回默认,不报错。就十来行,塞进项目里的 utils 就行。

哦对,还有件事得说:上面所有耗时都是我这一台机器上的数字,你换台 Windows 台式机、换个大文件,结果会差很多,别拿着我这张表去跟人抬杠。但"calamine 比 openpyxl 快一个数量级"这个结论,你自己随便跑一遍都能看到。

真正该记住的其实不是哪个库快。是你动手之前先问自己一句:这份 Excel,非读不可吗?我有好几次折腾到最后,是让上游把数据直接导出成 CSV 或者扔进数据库,问题当场就没了。工具再好,也不如换掉那个不该存在的环节。

🏷️ 标签: