Python 读取 Excel,第一次让人体会到明显耗时压力,往往就发生在“10 万行”这个量级附近。上周有同事拿一张接近 10 万行的业务表来问我:到底哪个库读 Excel 最快?我反问:你说的是真 .xlsx 文件,还是从 Excel 另存出来的 CSV?里面有多少列?数据是纯数字,还是混着日期、文本、合并单元格和公式?他愣了一下。这个反应很正常。因为大多数“选库”问题,真正缺的并不是候选名单,而是一套统一测试方法。只盯着“openpyxl 慢还是 pandas 快”这类结论,却不知道文件长什么样、完整读取要走到哪一步,测出来的耗时往往只是一张没有背景的数字。
10 万行 Excel 不像表面看起来那么简单。它既不是一份大文本,也不会因为 Python 很灵活就自动变得高效。真正值得写下来的,是一次公平测试的完整思路:先给文件画像,再定义清晰读取目标,然后用可控样本计时,最后结合稳定性、内存和维护成本做选择。
1. 为什么“10万行”会成为 Python 读取 Excel 的关键压力点
1.1 Excel 的格式结构,决定了解析成本不会太低
先说一个经常被忽略的事实:.xlsx并不是一个可以直接顺序读入的二进制表格文件。它是一个压缩包,里面装着一堆 XML 文件,不同 XML 分别记录单元格数据、字符串表、样式、列宽、行高、工作表关系等内容。
Python 要读取它,至少需要经历几层处理:
- 读取本地文件到内存;
- 解压 zip 包;
- 解析
xl/worksheets/sheet1.xml这样的工作表内容; - 解析并关联共享字符串、样式、日期格式;
- 把单元格坐标和值映射成 Python 对象或 DataFrame。
这一串动作做完,才能回答“我读到了第几行”。所以在 xlsx 场景下,Python 性能没法像读 CSV 那样接近“纯文本按行切分”,因为格式本身携带的复杂度完全不同。
我见过一些人拿 10 万行 Excel 测速后,得到“openpyxl 太慢”的结论,但换到相同内容的 CSV 后,pandas.read_csv非常快。会这样并不全是库的问题,更核心的是文件结构差异。把 xlsx 和 CSV 放在同一个速度对比里,前提都不一样,结果自然容易误判。
1.2 真正影响耗时的不只是行数,而是行列规模和单元格复杂度
“10 万行”只是文章标题里的一个粗略表达。真实做测试时,只说 10 万行远远不够。
同样是 10 万行,两个文件的实际工作量可能差出几倍甚至十几倍:
- 10 万行 × 3 列,只有 30 万个单元格;
- 10 万行 × 30 列,已经有 300 万个单元格;
- 10 万行 × 80 列,就是 800 万个单元格。
如果列数是 200 列或更多,文件体积和解析压力会明显上升。很多人只关注“行数 10 万”,却忽略了列数对总耗时的放大作用。做性能测试时,最好同时记录行列规模,至少把公式写成行数 * 列数的矩阵大小,而不是单独报 10 万行。
除了行列数,单元格内容类型也很重要:
- 纯数字表解析成本较低;
- 混合日期时,需要做序列号到 datetime 的转换;
- 大量文本时,长字符串本身会影响内存;
- 如果文件里有合并单元格、条件格式、图表、公式缓存,读取库还要处理这些信息,解析成本会明显增加。
还有一种情况容易被低估:文本都集中在公共字符串表里,这个表越大,解析时长越长。表面看只有 10 万行数据,可能只是某一个文本列里放了很长的备注。字符串一旦变长,内存和耗时都会变得不可预测。
1.3 测速前,先给 Excel 文件做一次“画像”
很多人拿到文件直接就开始pd.read_excel,我觉得这个问题要反过来想:如果你连文件里有多少列、字符串表大不大、是不是纯数据表都不清楚,那计时结果就只能用一次,不能复用到下一个文件。
给文件做画像不一定要打开 Excel。因为.xlsx本身是 zip 包,可以用 Python 直接查看里面关键文件的大小:
import zipfile path = "sample_100k.xlsx" with zipfile.ZipFile(path) as zf: for info in zf.infolist(): if info.filename.startswith("xl/worksheets/sheet1.xml"): print("sheet1.xml", round(info.file_size / 1024 / 1024, 2), "MB") if info.filename == "xl/sharedStrings.xml": print("sharedStrings.xml", round(info.file_size / 1024 / 1024, 2), "MB") if info.filename == "xl/styles.xml": print("styles.xml", round(info.file_size / 1024 / 1024, 2), "MB")如果sheet1.xml和sharedStrings.xml都很大,说明瓶颈大概率在 XML 解析和字符串处理;如果styles.xml很大,说明样式负担重,这也会让读取变慢。
在做“常见库读取耗时测试”之前,先记录这几个信息:
- 真实扩展名与文件格式;
- Sheet 数量;
- 有数据的行列范围;
- 是否包含公式、合并单元格、样式;
- 共享字符串表大小。
这个动作花不了几分钟,但能帮你判断:这次测试结果到底适用于哪种类型的工作簿。
2. xlsx、xls、csv:同一个“Excel”,完全不同的博弈场
2.1 三种文件的底层差异
很多人嘴里说“读 Excel”,实际文件可能是三种完全不同的东西。为了让测试结果不误导自己,最好先明确这三类文件的读取路径差异:
| 扩展名 | 底层结构 | Python 读取时的主要成本 | 适用说明 |
|---|---|---|---|
| .xlsx | zip 压缩的 XML 集合 | 解压 + XML 解析 + 单元格对象转换 | Excel 常见默认工作簿格式 |
| .xls | OLE2 复合文档二进制 | 二进制块解析,老库支持受限 | 老系统遗留文件,一般需要额外处理 |
| .csv | 纯文本 | 文本切分、编码识别、类型转换 | 本质不是 Excel 原生格式,只是表格数据 |
CSV 文件可以用 Excel 打开,但它不含多 Sheet、公式、样式这些 Excel 特性。把 CSV 当 Excel 来测速,只能代表“纯表格文本的读取速度”,不能代表解析一个真正工作簿的成本。
2.2 版本坑:工具库的支持范围经常影响公平性
测速时最容易踩的坑,不是库之间相差多少秒,而是本地安装的版本已经改变了能力边界。
举个典型例子:xlrd这个库曾经是读取 xlsx 的常用选项之一,但在版本更新后,新版重心回到.xls,对.xlsx的支持策略发生变化。如果你照着老教程用xlrd去读 xlsx,可能直接报错,或者得到一个非常慢的结果。
pandas 的read_excel只是外面包了一层,内部真正干活的是不同 engine。同一个pd.read_excel,在不同 pandas 版本下,或者本地安装依赖不同的情况下,实际走的解析路径可能并不一样。这篇文章不负责定义“哪个版本一定怎么样”,而是提供一个建议:做耗时测试时,把库版本也固定下来。
pip list | grep -i -E "pandas|openpyxl|xlrd|python-calamine|xlsxwriter"如果以后升级了某个依赖,尤其是 openpyxl 或 pandas,建议重新跑一次原有样本,而不是默认相信旧排行榜。
2.3 统一测试样本生成:先用 xlsxwriter 生成,避免写入干扰
想比较不同库读同一个 10 万行 Excel,第一步是准备一个所有库都能读取而且内容可控的文件。生成样本的时候,一般建议使用xlsxwriter,因为它在写大量 xlsx 数据时性能相对更好,不会让“生成文件”这一步浪费太久。
下面的代码只是一个示例结构,用来生成 10 万行 × 20 列的文件:
import xlsxwriter rows = 100000 cols = 20 path = "sample_100k.xlsx" workbook = xlsxwriter.Workbook(path) worksheet = workbook.add_worksheet() headers = [f"col_{i}" for i in range(cols)] worksheet.write_row(0, 0, headers) for row in range(1, rows + 1): line = [row] for col in range(1, cols): line.append(f"text_{row}_{col}") worksheet.write_row(row, 0, line) workbook.close() print("finished")如果希望更贴近真实业务,可以再混入日期、数字、百分比、较短文本和少量空值。测试文件越接近真实文件,最终选型结果越有参考价值。
3. 主流的几种候选:普通模式、只读模式、pandas 引擎和 Rust 解析器
3.1 openpyxl 的默认模式:灵活,但对象模型偏重
openpyxl是目前最常用的 Excel 读写库之一。它的默认模式要把工作簿加载成一套完整对象模型,包括工作表、单元格、样式等内容。这样做的好处是读写方便,能处理 Excel 的很多细节;代价是内存占用高、读取慢,尤其在 10 万行这种规模下。
普通模式下,一个比较简单但很“重”的写法是:
from openpyxl import load_workbook path = "sample_100k.xlsx" wb = load_workbook(path, data_only=True) ws = wb.active rows = list(ws.iter_rows(values_only=True))上面这段代码会先把整个工作表读进内存,再一次性转成列表。读取之后如果你还要继续操作单元格、Sheet、格式,这种模式最直观。但如果你只是想把数据提出来交给 pandas 或其他逻辑,直接使用这种模型,常常是“慢”的主要来源。
3.2 openpyxl 的 read_only 模式:给内存减负,但要改变思路
openpyxl提供了read_only=True参数。在这种模式下,工具不再预先构建整张工作表的对象模型,而是按行读取,适合处理大文件时逐行检查或转存。
from openpyxl import load_workbook path = "sample_100k.xlsx" wb = load_workbook(path, read_only=True, data_only=True) ws = wb.active row_count = 0 for row in ws.iter_rows(values_only=True): # 逐行处理,不要把数据都存到 list 里 row_count += 1 wb.close() print(row_count)这种模式的最大特点是:它更像一个迭代器,而不是把所有数据塞进内存。优点是比较省内存,适合做行数统计、抽样、格式转换;缺点是随机访问不方便,不能像普通模式那样像操作 Excel GUI 一样自由跳到任意单元格。
所以,在对比 openpyxl 默认模式和 read_only 模式时,应该先说明你的目标到底是“全量加载到内存”还是“只做一次流式处理”。如果目标是全量加载到内存,只拿 read_only 来对比就不公平。
3.3 pandas + engine:方便分析,但要看清楚 engine 是谁
pandas.read_excel最大的价值是让 Excel 数据直接进入 DataFrame,后面可以直接做过滤、分组、合并。它是“数据读取 + 表格组装”的组合。
import time import pandas as pd path = "sample_100k.xlsx" start = time.perf_counter() df = pd.read_excel(path) print(f"pandas read_excel: {time.perf_counter() - start:.3f}s") print(df.shape)但要注意,pd.read_excel并不是一个独立解析器,它默认还是要靠底层 engine 干活。不同引擎的解析方式不同,读取耗时和内存表现也会有明显差异。
如果只是想先了解数据集结构,可以用nrows读前面几行:
df_head = pd.read_excel(path, nrows=1000)如果业务只需要某几列,可以优先使用usecols参数,只读取需要的列。这个操作能显著减少加载量,但很多人在最初测试时没有把它纳入设计,导致结果偏高。
3.4 python-calamine 这类新引擎:先做小样验证,再决定是否拥抱
近些年社区里讨论比较多的是一批基于 Rust 解析思路的库,其中能接入 pandas 的方案是python-calamine。它的底层解析器不是纯 Python,所以在很多场景下会比传统方式更省时,尤其是在大文件、重复解析的场景里。
如果在较新版本的 pandas 中安装了对应依赖,可以尝试把 engine 指定为calamine:
df = pd.read_excel(path, engine="calamine")我并不是建议所有人都直接切换到这类库。新引擎可能在处理常见 xlsx 时表现不错,但如果你的真实文件里有大量合并单元格、特殊公式、旧版 xls、复杂样式,就可能出现兼容性差异。更稳妥的方式是:先拿日常最脏的那几个文件做小样本测试,确认读取到的行数、关键列、日期格式都和旧方式一致,再决定是否替换。
从工程经验看,这类新库非常适合“已经确认自己只读取值、不需要关心具体样式细节”的流程。如果你的目标只是把工作表数据提出来做分析,那么值得纳入候选。
3.5 CSV/read_csv:如果业务允许,这是另一种优化思路
如果读取耗时已经成为瓶颈,但又拿不到更好的数据格式,可以考虑在源头增加一步转换:让上游把 Excel 另存为 CSV,或者用 Python 脚本把 xlsx 转成 CSV 后再读。
pandas.read_csv的底层路径通常比解析 xlsx 轻量很多。但这不代表所有 Excel 场景都该转成 CSV,因为:
- Excel 里的多个 Sheet 没有办法直接放进一个 CSV;
- 公式、单元格格式、日期显示规则会丢失;
- CSV 本身没有严格类型描述,读的时候需要自己确认编码和分隔符。
所以这个方案适合“数据内容很规整、只需要做分析、上游愿意配合导出”的业务场景。如果文件必须保留 Excel 语义,CSV 不能算同一条赛道。
4. 设计一次能避免误导的耗时测试
4.1 先定义问题:你测的是“全量加载”还是“流式遍历”
耗时测试开始前,最重要的一个问题:最终业务逻辑是否要求一次性把全部数据加载到内存?
如果只是做行数校验、抽样检查、转存导出,那么更适合用流式/只读模式,计时也应该基于“所有行都被遍历完”来算。如果最终要做数据分析和 DataFrame 操作,那么必须按 pandas 或全量加载方式计时,因为从 Excel 读出原始行之后,还要转换成 DataFrame,这个转换本身也占时间。
最容易被低估的测试误差,是只读取开头几行就结束计时。比如有人为了测openpyxl.read_only的性能,在循环里读到第 1000 行就break,然后得出结论说它很快。这个结论对真实业务没有意义,除非你的业务也只读前 1000 行。
我的建议是:每个候选方式都必须“完整消费完同一个数据读取目标”,再做时间比较。不要在计时前把“提前终止”当成优化变量混进来。
4.2 一个最小测速脚本骨架
下面是一段“示意结构”级别的测试代码,用来帮助你理解测速思路。具体参数需要根据文件路径、库版本、机器环境调整:
import time import statistics import pandas as pd from openpyxl import load_workbook path = "sample_100k.xlsx" def load_openpyxl(): wb = load_workbook(path, data_only=True) ws = wb.active rows = list(ws.iter_rows(values_only=True)) wb.close() return rows def load_openpyxl_read_only(): wb = load_workbook(path, read_only=True, data_only=True) ws = wb.active rows = [tuple(row) for row in ws.iter_rows(values_only=True)] wb.close() return rows def load_pandas(): return pd.read_excel(path)然后可以写一个简单的计时函数,每个方法运行 3 到 5 次,取中位数:
def time_it(func, repeat=5): times = [] for _ in range(repeat): start = time.perf_counter() result = func() times.append(time.perf_counter() - start) return statistics.median(times), result这种写法的问题也很明显:同一个 Python 进程连续读取同一个文件,第二次读时文件内容可能已经被系统缓存,速度会比第一次快一些。如果要在文章里发布结论,我会建议至少分成两种场景:
- 冷启动:每次读取用新进程或新环境执行,模拟第一次任务;
- 热加载:连续读取同一文件,模拟重复任务或缓存命中场景。
不用太纠结于精确到毫秒,但要在记录里写清楚看的是冷启动还是热加载,否则两个库的性能差异可能来自操作系统缓存,而不是解析算法本身。
4.3 记录耗时之外的必要指标
做性能测试时,只记录耗时是一个常见盲区。你需要同时记录其他指标,否则结果很可能误导实际选型。
| 要记录的指标 | 为什么重要 | 如何获取 |
|---|---|---|
| 读取行数和列数 | 防止不同库读取结果不一致 | 读取后打印 shape 或行数 |
| 数据摘要 | 防止“快”是因为少读了内容 | 对关键列求和、计数或哈希 |
| 耗时中位数 | 避免单次偶然性 | 多次运行取中位数 |
| 内存占用 | 大文件可能不是慢,而是直接撑爆内存 | psutil 或用系统监控 |
| 依赖版本 | 库升级后结果会变化 | pip list 固定版本 |
| 文件大小 | 用于判断是否与网络/磁盘相关 | 查看文件元信息 |
如果你想知道整个 Python 进程的内存峰值,最简单的方式是使用psutil:
import psutil import os def current_rss_mb(): return psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024在读取前后各记录一次current_rss_mb(),就能粗略看出读取过程新增了多少内存。
4.4 校验读取结果:快可以接受,但“读少了”不能接受
性能测试最容易犯的自欺错误,不是计时误差,而是两个读取方式返回了不同的列或行,却没人发现。
例如,有的库对“完全空白的行”处理策略不同;有的读取方式对“合并单元格”只保留左上角的值;有的方式默认不读取公式结果,只返回公式字符串。这些差异不会让程序崩溃,但会让最终结果产生隐性偏差。
一个简单的校验方式是,在读取后统一计算摘要:
def compare_summary(rows, label): if hasattr(rows, "shape"): print(label, "shape:", rows.shape) print(label, "sum:", rows.iloc[:, 0].sum()) else: print(label, "rows:", len(rows)) print(label, "col0_sum:", sum(row[0] for row in rows if row and isinstance(row[0], (int, float))))如果两个读取方式返回的 shape 或求和结果不一样,宁可先排查差异原因,也不要直接比较耗时。
5. 测试结果出来后,别直接选最快,先补三块判断
5.1 快,可能来自惰性处理,而不是真正解决问题
有些读取方式看起来很快,是因为它返回了一个迭代器或延迟对象,真正解析数据的动作还没有发生。当你后续遍历所有行或执行 DataFrame 操作时,真实耗时才会爆发。
因此,选择“快”的方式前,先问一个问题:我拿到这个对象之后,还要做什么?
如果后面是for row in ws.iter_rows(),那read_only的耗时有意义;但如果后面要执行df.groupby(...),那read_only返回的行迭代器还需要额外组装,流程会更复杂,未必划算。这是很多人直接照搬网上代码后仍然慢的原因所在。别只看读取函数那一条语句,要把后面真正处理数据的那几步也包含进计时。
5.2 干净样本冲抵不了真实文件的“脏度”
测试文件通常是规整的:没有合并单元格、没有复杂公式、没有大量空白列。真实业务 Excel 可能很脏。脏数据会对读取造成至少三类影响:
- 单元格里有公式引用外部链接或原格式残留,导致解析返回的不是你期望的值;
- 一个单元格里有很长的换行文本,导致
sharedStrings.xml很大; - 表格“看似 10 万行”,实际最大行已到几十万,因为