ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

用Python Pandas批量汇总Excel:从复制粘贴到自动化流水线

用Python Pandas批量汇总Excel:从复制粘贴到自动化流水线

每到月底,财务和运营的小伙伴就开始经历至暗时刻:一堆格式五花八门的Excel表格从各个门店、渠道、区域飞过来,要把它们汇总成一张总表。我刚接手这种活儿的时候也天真地以为打开Excel挨个复制粘贴就能搞定,直到面对2000多个文件、接近百万行流水的时候才明白,这条路根本走不通。这篇文章记录的是我用Python的Pandas库把“汇总大量Excel”这件事彻底自动化的全过程,从批量读取、数据清洗到性能优化、踩坑排查,再到汇总之后的统计扩展。适合每天被表格折磨的运营、财务和数据分析新人,也适合已经会一点Python、但不知道怎么下手处理成批Excel的朋友。

1. 为什么Excel自带的功能不够用

1.1 手工复制粘贴的崩溃现场

先说一个真实的场景。某一次月度汇总,我桌面上躺着来自几十个门店的销售明细,每个文件里有好几个Sheet,每个Sheet几百到几千行不等。一开始我按老办法,打开一个文件、选中数据、复制、切到总表、粘贴,再打开下一个。前二十个文件还好,到了第五十个文件时眼睛已经花了,最要命的是有两个文件的结构跟其他文件不一样——前面的Sheet是“销售明细”,后面这两个却换成了“出库明细”和“销售明细(含退货)”,等我发现时已经粘错了两屏数据,只能删掉重来。

这种活儿最大的问题不是技术难度,而是重复劳动带来的注意力和耐心消耗。人不是机器,连续做半小时机械操作一定会出错。漏一个文件、多复制一行、某个Sheet没拉到最底下,都会让最后的总数对不上,而且错了还很难查。等到复盘的时候,你根本说不清哪一步漏了,只能重新对着原始文件一个个核对,比直接从零开始做还难受。

1.2 用VBA也没那么省心

我知道很多同学的第一反应是:既然要自动化,那用Excel自带的VBA宏不就行了?坦白说,VBA确实能做批量汇总,我在早期也写过不少宏,但它有几个很实际的问题。

VBA和Excel的耦合太深,脚本一旦跑起来界面就卡死,文件一大就得“假死”几分钟。更麻烦的是,不同门店发过来的表格结构经常歪七扭八——有的表头在第2行,有的表头在第1行;有的用“金额”,有的用“销售额”;有的金额列里还混着“元”这个单位。VBA处理这种差异需要写一堆分支判断,代码长度翻几倍,而且报错信息非常不友好,动不动就弹一个“运行时错误9”之类的提示,排查半天也不知道错在哪一行。最关键的是,这套宏只能留在装好Excel的电脑上跑,换台环境就要重新设置宏安全性,别人接手也完全看不懂。VBA让我学会了什么叫“能用,但不好用”。

1.3 Pandas的做法好在哪

后来我转到Python生态,第一次用Pandas做批量汇总的时候,最大的感受是:Pandas把“表格”这种东西当成数据对象来操作,而不是当成界面上的格子。你不用管单元格长什么样、有没有边框、字体颜色是红是蓝,你只关心列名、索引、数据类型,这刚好是汇总工作真正需要的视角。

而且Pandas的流水线式处理非常契合这个场景:读文件是读文件,清洗是清洗,合并是合并,输出是输出,每一步之间可以独立调试。哪个文件读进来格式不对,单独打印这个DataFrame就能看清楚;哪一列类型不对,一行代码就转换完事。对比手工粘贴和VBA,代码的可读性、可扩展性和健壮性都强了一个量级。更不用说Pandas背后有完整的社区生态,你遇到什么奇怪的报错,基本都能在教程和问答里找到同款经历。

2. 动手前先定规矩:目录、引擎和汇总口径

2.1 文件目录怎么放才不会乱

很多人拿到一堆Excel就直接写代码,读文件的路径里还带着中文空格和括号,跑通一次之后换个月份又得改路径。我的建议是:在项目根目录下固定建一个文件夹专门放待汇总的原始文件,比如./data/raw/,所有从微信、邮箱、钉钉下载下来的表格统一丢进去,脚本永远只扫描这个目录,不要散落在桌面和下载文件夹里。

目录结构建议这样:

excel_summary/ ├── data/ │ ├── raw/ # 原始Excel,全部丢这里 │ └── output/ # 汇总结果输出到这里 ├── scripts/ │ └── summary.py # 汇总脚本 └── docs/ # 字段说明、命名规范等文档

这个习惯看起来简单,实际价值很大。第一,脚本里的路径恒定,不用每次改;第二,原始文件集中存放,方便回查和备份;第三,跑完的产物和原料分开放,不会把原始表和结果表混在一起导致下次汇总把上次的结果又读进去了。

2.2 读取引擎怎么选

Pandas读取Excel文件主要通过pd.read_excel,但真正负责解析文件的是底层引擎。这里有几个选择,我踩过坑之后才搞清楚区别。

引擎支持格式特点
openpyxlxlsx功能全面,读写都支持,日常首选
xlrdxls(新版)老牌引擎,旧版支持xlsx但现在只适合读xls
python-calaminexlsx/xls/xlsb等多种读取速度很快,但返回的是原生Python对象,需要Pandas再转换

我平时处理标准现代格式的xlsx文件就用默认引擎,实际默认的写法就是pd.read_excel(path),它会自动选择openpyxl(新版Pandas对xlsx默认走openpyxl)。只有在文件特别多、对读取速度敏感的时候,才考虑用calamine引擎。


提示:如果你的环境读取xlsx报错类似“Missing optional dependency ‘openpyxl’”,说明没装对应的引擎,直接pip install openpyxl就行。这是新手最容易卡住的第一步,跟Pandas本身没关系。

2.3 表头不齐时先说好“汇总口径”

汇总工作最忌讳的是脑子里没有一个统一的“标准表”,拿到什么就合并什么。我在做项目前一定会先跟需求方确认清楚:最终总表需要哪些列,列名叫什么,各列的顺序是什么。

比如同样一份订单数据,财务关心的是“日期、门店、金额、收款方式”,运营可能还关心“渠道、商品、数量”。如果一开始不定义标准字段,等脚本跑完再发现少了一列,就得所有文件重读一遍,非常被动。我用过的一个笨而有效的办法是:先手动打开三五个典型的源文件,把它们的公共字段列出来,再跟需求方逐条核对,确认最终只保留其中有意义的字段。这一步花不了二十分钟,但能把后面返工的几率降到最低。

3. 汇总主脚本拆解:从glob到concat的完整链路

3.1 批量读取文件

核心思路其实非常简单,就三件事:找到所有文件、逐个读取成DataFrame、把所有DataFrame纵向合并。我第一次跑通的完整脚本大概是这样:

import pandas as pd import glob files = glob.glob("./data/raw/*.xlsx") print(f"共找到 {len(files)} 个文件") df_list = [] for file_path in files: df = pd.read_excel(file_path, engine="openpyxl") df_list.append(df) result = pd.concat(df_list, ignore_index=True) result.to_excel("./data/output/汇总总表.xlsx", index=False)

这段代码已经能解决“文件多、靠手粘”的问题。但你实际跑起来就会发现,还有不少细节需要打磨。glob.glob的返回值顺序不保证跟文件名排序列一致,这对汇总结果本身没影响,但如果你希望输出顺序和文件名顺序对应,可以先排序:

files = sorted(glob.glob("./data/raw/*.xlsx"))

另外,第一次跑的时候,我建议先打印每个文件的路径和它读进来的DataFrame行数、列数,确认所有文件都读对了再合并。这一步在代码里加两行即可:

for file_path in files: df = pd.read_excel(file_path, engine="openpyxl") print(file_path, df.shape) # 查看每个文件的行列数 df_list.append(df)

不要小看这两行打印,它能帮你立刻发现“有文件没被找到”或者“某个文件读进来是空的”这种低级问题。

3.2 列名统一与字段规整

文件一多,列名不统一马上就会暴露。比如有的表里叫“店铺”,有的叫“终端”,有的叫“门店名称”,如果直接concat,最终表里会出现好几列长得像但名字不一样的字段,分组的时侯就乱套了。

我的做法是维护一个列名映射字典,在每个文件读取之后统一rename:

COLUMN_MAP = { "店铺": "门店", "终端": "门店", "门店名称": "门店", "销售额": "金额", "销售金额": "金额", "含税金额": "金额", "日期": "日期", "销售日期": "日期", } def read_file(file_path): df = pd.read_excel(file_path, engine="openpyxl") df = df.rename(columns=COLUMN_MAP) return df

统一完列名之后,还有一个很常见的坑:同一个字段在不同文件里的数据类型不一样。“金额”在一个文件里是数值,在另一个文件里可能是因为单元格里加了“元”字或千分位符,变成了字符串。这类问题当场不处理,到求和的时候才会炸。所以我在读取后会对关键列做强制转换:

df["金额"] = pd.to_numeric(df["金额"], errors="coerce") df["日期"] = pd.to_datetime(df["日期"], errors="coerce")

这里errors="coerce"的意思是可以转换的转成数字/日期,转换不了的变成NaN(缺失值)。转换失败变NaN比直接报错好得多,因为你可以事后再定位到底哪几个单元格有问题,而不是让整个脚本中断。


注意:如果某个文件“金额”列因为表头没有对齐,被读成了其他列名,那么rename之后,df["金额"]可能根本不存在,直接赋列操作会报KeyError。稳妥的写法是加一个判断:if "金额" in df.columns: df["金额"] = ...,或者用df.get先检查。

3.3 把几千个DataFrame稳妥地拼起来

合并DataFrame最常用的两个函数是concat和append。需要特别说明的是:旧版Pandas里的df.append()已经废弃了,新版一定要用pd.concat()。

result = pd.concat(df_list, ignore_index=True)

ignore_index=True表示合并之后重新生成0、1、2……的连续索引,避免每个子表还保留自己原来的行号,这能防止后续处理时索引重复导致选取数据出错。

不过这里有一个性能细节值得讲清楚:如果你在一个循环里不断做result = pd.concat([result, df]),随着result越来越大,每次concat都会复制一遍全部数据,几百个文件下来会非常慢。正确的做法是像我前面代码那样,先把所有小DataFrame放进一个列表,最后一次concat。列表里攒的是引用,不是数据拷贝,最后只进行一次合并,效率高得多。

如果文件数量特别大,比如上万个文件,一次性把所有DataFrame都放进列表也可能撑爆内存。这种极端情况我放在下一章专门说,常规场景下用“列表+一次concat”就是最优解。

3.4 输出:一份带格式的总表

result.to_excel()是输出到Excel的标准方式,但如果你直接把合并后的几万行写进一个Sheet,打开文件的时候Excel会卡半天,而且没有筛选、没有汇总,领导看了也头大。所以我在输出总表之前,通常会顺手把数据处理得“更像一份报告”。

一种常见做法是:把关键字符串列统一去除首尾空格,把空值统一填充成0或者空字符串,避免最终表里出现一堆NaN。这在Excel里显示为空白格子,虽然不影响统计,但打印出来很丑。

result = result.fillna("") # 空值统一填充为空字符串 result["门店"] = result["门店"].astype(str).str.strip()

另外,我习惯把结果输出成多个Sheet:第一个Sheet放明细,第二个Sheet放汇总统计,这样一张工作簿就够用了。具体写法:

with pd.ExcelWriter("./data/output/汇总总表.xlsx", engine="openpyxl") as writer: result.to_excel(writer, sheet_name="明细", index=False) summary.to_excel(writer, sheet_name="汇总", index=False)

用pd.ExcelWriter而不是连写两个to_excel,是为了避免第二次to_excel把第一次的结果覆盖掉。这一点非常关键,我第一次写的时候就是分开写两个to_excel,结果文件始终只保留最后一个Sheet,查了半天才反应过来。

4. 文件过千之后:性能与内存实战优化

4.1 瓶颈到底在哪

一开始我以为几百个Excel文件的汇总会很耗时,实际测下来发现,Pandas处理数据的计算部分根本是毫秒级,真正的时间开销几乎全在读取文件上。一个几万行的xlsx用openpyxl读源码可能要一两秒,如果你读了几百个文件,光读取就得好几分钟。

所以优化的第一原则是:能不读的数据就不要读。很多原始表动辄几十列,但汇总真正用到的只有五六列。pd.read_excel里加一个usecols参数,只读取需要的列,IO和内存都能省下一大截。

df = pd.read_excel( file_path, engine="openpyxl", usecols=["门店", "日期", "金额", "备注"], dtype={"门店": "string"}, )

我在一次处理800多个文件、每个文件七八十列数据的任务里,只保留6列之后,读取时间从大约12分钟降到了4分钟出头。这个优化几乎不费脑子,收益却是肉眼可见的。

4.2 内存占用怎么压下来

很多人在汇总大Excel时遇到的内存问题,其实不是一次读完的数据有多大,而是同时积攒了大量DataFrame副本。比如我有段代码为了处理某个字段,写了下面这种链式操作:

df = df[df["金额"].notna()] df["金额"] = df["金额"].astype(float) df = df[["门店", "日期", "金额"]]

每执行一次赋值,Pandas都可能产生一个中间的副本,如果这些副本还被后面循环里的变量引用着,GC(垃圾回收)不会马上释放内存。循环几百轮之后,内存占用就会一路飙升。

我的经验是:在每个循环内部,处理完当前文件的临时变量尽量只保留最终需要的那份DataFrame,中间状态及时清掉。比如你可以每读完一个文件就把它压到最小再放进列表:

df_list.append(df[["门店", "日期", "金额"]])

另外,如果业务上不关心原始数据的格式细节,可以用pandas的字符串类型dtype="string"而不是Python对象来存储字符串列,内存占用会低很多。不过别忘了,这个转换也会带来一点点计算开销,属于用CPU换内存,文件特别多的时候不见得划算,需要自己权衡。

4.3 分块处理与落盘兜底

文件数量特别多、总行数可能超过千万的时候,别指望一次性把所有DataFrame合并在一起还能舒服地操作。这时候我用的策略是分批处理、分批落盘。

简单来说,就是每读入一批文件(比如100个),合并成一个小结果,立刻写入一个固定的中间文件,然后清空列表。最后再把这些中间文件合并成最终结果。这样在任何时刻内存里最多只保留100个文件的数据,不会持续膨胀。

CHUNK_SIZE = 100 files = sorted(glob.glob("./data/raw/*.xlsx")) df_list = [] for i, file_path in enumerate(files): df = read_file(file_path) df_list.append(df) if (i + 1) % CHUNK_SIZE == 0: # 这批先拼起来,落盘后清空列表 chunk_df = pd.concat(df_list, ignore_index=True) chunk_df.to_parquet(f"./data/tmp/chunk_{i//CHUNK_SIZE}.parquet") df_list.clear() # 最后一批不足CHUNK_SIZE的也要落盘 if df_list: chunk_df = pd.concat(df_list, ignore_index=True) chunk_df.to_parquet(f"./data/tmp/chunk_final.parquet") # 合并所有中间结果 final_list = [] for pf in sorted(glob.glob("./data/tmp/*.parquet")): final_list.append(pd.read_parquet(pf)) result = pd.concat(final_list, ignore_index=True)

这里我用to_parquet而不是to_excel来存中间结果,因为Parquet格式压缩率高、读写快,并且能保留数据类型。如果你环境里没装pyarrow,也可以用to_pickle或to_csv凑合,但Parquet在这个场景下是最舒服的。

4.4 读取引擎的终极加速

如果上面这些优化都做完了,读取速度还是不够,那我才会考虑换引擎。python-calamine这个库我实测在读取大批量xlsx时确实比openpyxl快不少,它底层是Rust实现,解析性能更好。Pandas里可以用专门的引擎参数来调用它:

df = pd.read_excel(file_path, engine="calamine")

不过要提醒一句:python-calamine作为Pandas读取引擎,对某些Excel特性(比如非常复杂的合并单元格、内嵌对象)的兼容性不如openpyxl稳。我在一个包含大量图表和批注的报表文件上遇到过读取结果异常的情况,后来还是回退到openpyxl处理那一个文件。所以我的建议是:常规文件用默认引擎,文件规模真的很大的时候,再把calamine用在一个独立分支里做实验性提速,不要一上来就全局替换。这样折中最稳。

5. 三十个文件里踩过的坑

5.1 表头附近的合并单元格

第一批文件跑完,我打开总表检查,发现有些行的“门店”列是空的,空行的上一行却占了两个格子的宽度。这就是源文件里表头部分用了合并单元格导致的:Pandas读进来之后,合并单元格只在左上角那个位置有值,其他位置被解析成NaN。

针对这个问题,我在读取之后对关键列做了一个前向填充,把空值补成上一个非空值:

df["门店"] = df["门店"].fillna(method="ffill")

但这里有个陷阱:如果数据本身就有需要保留的空值,比如某人某天确实没有销售记录,前向填充会把本来该空的地方也填错。所以填充前要先判断这列是否真的是“每一行都必须有值”的字段。门店、日期、单据编号这类业务上不允许为空的字段,可以放心填充;金额、数量这类数值字段就不要用ffill去碰。

5.2 隐藏的合计行和小计行

有一类恶魔隐藏得很深:源文件里除了明细数据,末尾还有一行“合计”,或者每隔几十行插一行“小计”。这些行在Excel界面里可能带着灰色底纹,肉眼一眼能认出来,但Pandas不管格式,统统当成正常数据读进来。汇总之后再统计一遍,就会把合计行的值又加了一次,总额直接翻倍。

我处理这个问题的排查链路是:先看结果里的“金额”列极值,发现有一个异常的极大值,然后打印出行数最多的几个源文件,按门店分组求和,把求和结果跟源文件最后的“合计”对比,定位到具体是哪几个文件混入了合计行。

最终修复方案也很简单,在读取后过滤掉这些标志性行:

df = df[~df["门店"].astype(str).str.contains("合计|小计|总计", na=False)] df = df[df["金额"].notna() & (df["金额"] != 0) or ...]

注意这里的逻辑别弄反了:过滤条件是“剔除包含合计”。我当时第一次写成了保留包含合计,导致只剩下合计行,差点没笑出声来。

5.3 同名字段类型不一致

有的文件“日期”列是真正的Excel日期类型(日期格式),有的文件“日期”列是文本字符串“20240115”或者“2024-01-15 10:30:00”,合并之后这一列变成object类型,排序和分组全都乱掉。

我的处理方式是:读取时不指定类型,拿到列之后统一按照一种格式去解析。关键点是不要天真地认为原始数据的格式是统一的,一定要统一做一次to_datetime:

df["日期"] = pd.to_datetime(df["日期"], format="%Y%m%d", errors="coerce") df["日期"] = pd.to_datetime(df["日期"], errors="coerce")

这里有个小技巧:如果你调用两次to_datetime,第一次用不带格式的宽松解析,其实大多数日期字符串都能解析。上面代码里我先用format="%Y%m%d"再宽松解析的方式,是为了让“20240115”这种格式优先按YYYYMMDD解析,避免它被误判成别的日期。实际使用中,我建议先跑一次宽松解析,看结果对不对,再决定要不要加format参数。

5.4 非法字符与编码报错

读取Excel文件时最常见的报错是UnicodeDecodeError或ValueError: ... illegal multibyte sequence,不过这类报错通常不是出现在xlsx上,而是出现在把DataFrame输出成CSV的时候。比如数据里有特殊符号(换行符、Tab、不可见字符),Excel能显示但CSV编码处理不了。

我遇到过一种情况:某个文件里“备注”列夹带了几个Excel里特殊的竖向Tab字符,输出CSV时报错。排查的过程比较吃力,后来我学乖了,输出前统一做一次字符清洗:

df["备注"] = df["备注"].astype(str).str.replace(r"[^\u4e00-\u9fffA-Za-z0-9,。、:;()\-\s]", "", regex=True)

这个正则比较粗暴,但效果立竿见影:把非中文、非英文、非数字和常见标点之外的字符都去掉。当然,如果你的业务数据里真的需要保留特殊符号,这个正则要按需放宽。

6. 汇总完顺手把统计分析也做了

6.1 按门店/部门分组汇总

数据汇总只是第一步,领导真正要看的往往是“哪个门店卖得好”“哪个月波动最大”。既然数据已经都在一个DataFrame里了,顺手做统计几乎是零成本。最常用的就是groupby。

summary = ( result.groupby("门店", as_index=False)["金额"] .sum() .sort_values("金额", ascending=False) )

如果还想看每个月的趋势,就把“日期”字段先转成月份,再分组:

result["月份"] = result["日期"].dt.to_period("M") monthly = ( result.groupby(["门店", "月份"], as_index=False)["金额"] .sum() )

这里to_period("M")会把日期统一变成月份周期,比如2024-05,后续绘图和透视都很方便。这个扩展通常不需要重新读文件,所以你可以在汇总脚本后面直接往下加代码。

6.2 用数据透视表看交叉统计

pivot_table能帮你在几分钟内做出一张“门店 × 月份”的交叉表,比在Excel里手工拖字段快得多:

pivot = pd.pivot_table( result, values="金额", index="门店", columns="月份", aggfunc="sum", fill_value=0, )

透视表最大的好处是缺失值直观地显示为0,而不是留下一堆NaN。我之前用groupby做交叉统计时,某个月没有数据的门店会直接缺失,在Excel里看就是空格,领导会觉得数据有问题。用pivot_table加fill_value=0之后就干净了。

6.3 把统计结果拆分成多个Sheet输出

我在前面讲过用pd.ExcelWriter输出多Sheet。这里再补一个实用点:如果你希望每个门店单独生成一个Sheet(比如有些同事只看自己那一片),可以用循环配合writer来实现:

with pd.ExcelWriter("./data/output/分店统计.xlsx", engine="openpyxl") as writer: for store, store_df in result.groupby("门店"): store_df.to_excel(writer, sheet_name=str(store)[:31], index=False)

要注意Excel的Sheet名最长31个字符,且不能包含\/?*[]这些特殊字符,所以用str(store)[:31]先截断一下。分组名里如果有非法字符,to_excel会直接报错,这个坑我踩过一次,后来养成了先清洗Sheet名的习惯。

6.4 定时任务自动化的思路

脚本跑通一次之后,每个月重复劳动又开始了。其实可以再往前走一步:把这个汇总脚本接到定时任务里,让系统自动跑。

在Windows上可以用计划任务,在Linux服务器上可以用cron。核心就是把你手动执行的Python命令变成一条固定指令,比如:

cd /path/to/excel_summary && python scripts/summary.py

然后把原始文件下载这一步也尽量自动化:只要能保证每天固定时间把文件扔进./data/raw/目录,脚本就会自动产出最新的汇总表。我目前的做法是把脚本挂在服务器上,每天早上自动运行一次,把结果输出到共享目录,相关同事直接打开就能看到最新数据。

不过这里我不建议一上来就搞得太复杂。先把单机脚本跑稳定,再考虑定时任务。很多真实项目的乱子并不是脚本逻辑出错,而是源文件名带空格、文件扩展名大小写不一致、目录权限没配置好,定时跑的时候就各种花式失败。先让脚本在你手边稳定运行半个月,再拿去做自动化,这样出问题时你能快速定位,而不是面对一个凌晨三点跑挂的任务日志发呆。


最后说一点个人体会。汇总大量Excel这件事,真正难的不是“写代码”,而是“把规则想清楚”:哪些文件要读、哪些字段要留、哪些行要过滤、哪些类型要转换。写代码只是把这些规则落地。Pandas的价值在于它逼迫你把这些事情显式地写出来,而不是靠肌肉记忆复制粘贴。等你跑通这一套流程,下一次再来一批格式完全不一样的表格,你也不会慌——换一批read逻辑、改一改列名映射,十几分钟就又是一条新的流水线。

RELATED READING

延伸阅读

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