先说个事,我上周差点把笔记本摔了
朋友是做服装批发的,甩给我一个 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 或者扔进数据库,问题当场就没了。工具再好,也不如换掉那个不该存在的环节。