做数据整理这行的朋友大概都有过这种体验:月底对账、季度汇总、项目结项的时候,邮箱里、共享盘里躺着十几个甚至几十个Excel文件,命名五花八门,表头有的在第一行有的在第三行,列的顺序还各不相同。手动打开、复制、粘贴、对齐字段,一套流程下来两三个小时就没了,眼睛花不说,还特别容易漏行或者错位。我见过太多人在这件事上反复消耗时间,其实这类重复劳动完全可以用一段Python脚本一次性解决,跑一次几秒钟,跑一百次也是几秒钟。
这篇内容聊的就是用Python快速合并文件夹内Excel数据这件事。它解决的核心问题很具体:把一个目录下散落的多个Excel文件(.xlsx、.xls、.xlsm等)按统一规则读取、清洗、拼接成一张总表,并输出成可直接使用的文件。适合的人群也明确:经常处理报表的运营、财务、行政人员,做实验数据汇总的学生和研究人员,以及刚学Python想找一个真正能用上的练手项目的初学者。哪怕你之前只写过print("hello"),跟着下面的思路和代码走一遍,也能把这套流程跑通并改成自己需要的版本。
1. 整体设计思路与方案选型拆解
1.1 为什么不用手工和Excel自带功能
先说清楚为什么不推荐手工合并。手工合并的问题不只是慢,更在于不可复现。今天你合并了20个文件,明天又来5个,你得重新判断哪些已经合过、哪些没合,字段有没有变化。一旦中间出错,回溯成本极高。Excel自带的"合并计算"或者Power Query虽然能做一些事,但面对表头不一致、多级表头、合并单元格这些常见脏数据,配置起来并不比写代码轻松,而且换个文件夹就得重新配一遍。
用Python做这件事的核心优势是规则可固化。你把"读哪些文件、跳过哪些行、保留哪些列、怎么对齐字段"这些逻辑写成代码,它就变成了一个可以反复执行的资产。下次数据来了,改一下文件夹路径直接跑,逻辑完全一致,结果可预期。这也是我建议所有做周期性报表的人尽早掌握这套方法的原因——一次投入,长期复用。
1.2 技术栈选择:pandas为主,openpyxl和xlrd为辅
合并Excel这件事,绕不开三个库,各自的分工要搞清楚,否则很容易在版本兼容上踩坑。
| 库名 | 作用 | 适用格式 | 关键说明 |
|---|---|---|---|
| pandas | 数据读取、拼接、清洗、导出 | 依赖底层引擎 | 核心操作库,提供read_excel和concat |
| openpyxl | 读写.xlsx/.xlsm | 新版Excel | pandas读取xlsx时的默认引擎 |
| xlrd | 读取.xls | 旧版Excel | 新版xlrd已不支持xlsx,只用于老格式 |
这里有个新手最容易栽的坑:xlrd从2.0版本开始不再支持.xlsx格式。很多老教程还在写pd.read_excel(file)然后报错,原因就是环境里装的是新版xlrd而文件是xlsx。正确的做法是明确指定引擎,或者干脆统一用openpyxl处理xlsx。我的习惯是:xlsx一律走openpyxl,遇到xls再单独用xlrd处理,代码里做格式判断。
选pandas而不是纯openpyxl逐单元格读取,理由也很实在。pandas的read_excel一次性把整张表读成DataFrame,后续的列筛选、类型转换、缺失值处理、多表拼接都是一行方法调用的事。用openpyxl你得自己遍历行、判断表头、手动append,代码量翻好几倍,还容易在空行和合并单元格上出问题。除非你要保留原文件的格式、公式、图表,否则数据处理场景一律优先pandas。
1.3 合并逻辑的三种典型模式
在动手写代码前,先想清楚你的合并属于哪一种,这决定了代码结构。
第一种是同构合并,所有文件表头完全一致,直接纵向堆叠即可,最简单。第二种是异构对齐合并,各文件列名或列顺序不同,需要按列名对齐,pandas的concat默认按列名对齐,缺的列自动补NaN,这个特性很好用。第三种是带来源标记的合并,合并后需要知道每一行来自哪个文件,这时候要在读取时加一列记录文件名。
我实际项目中遇到的大部分是第二种和第三种的混合:表头大体一致但有个别列名写法不同(比如"金额"和"金额(元)"),同时需要保留来源信息方便追溯。下面的代码会以这个最贴近真实的场景为主线展开,你可以根据自己的情况裁剪。
2. 核心细节解析与实操要点
2.1 文件遍历与格式过滤的正确写法
遍历文件夹看起来简单,但有几个细节不注意就会出问题。第一,要区分当前目录和子目录,os.listdir只列当前层,glob或Path.rglob可以递归。第二,要过滤掉Excel打开时产生的临时文件,典型的是以~$开头的隐藏文件,这类文件用pandas读会直接报错。第三,要按扩展名白名单过滤,避免把csv、txt甚至图片混进来。
我习惯用pathlib,比os.path更直观:
from pathlib import Path folder = Path(r"D:\报表数据\2024Q3") files = [f for f in folder.iterdir() if f.suffix.lower() in (".xlsx", ".xls", ".xlsm") and not f.name.startswith("~$")]这里f.suffix.lower()统一转小写,防止有人把文件存成.XLSX导致漏掉。startswith("~$")过滤临时文件这一步千万别省,我见过不止一次有人卡在这里,报错信息还特别隐晦,提示"File is not a zip file",其实是读到了临时文件。
注意:如果文件夹里还有子文件夹且需要递归,把
iterdir()换成rglob("*"),但递归时要留意会不会把备份目录里的旧文件也读进来,建议加一层目录名过滤。
2.2 表头识别的三种策略与取舍
表头是合并Excel最容易翻车的地方。理想情况是每个文件第一行就是干净的列名,但现实往往是:有的文件前面有两行标题说明,有的表头在第3行,有的甚至是两行合并的多级表头。针对不同情况,我总结了三套策略。
策略一:固定skiprows。如果你知道所有文件的表头都在第N行,直接pd.read_excel(file, skiprows=N-1)。简单粗暴,适合格式高度统一的场景,比如系统导出的报表。
策略二:动态探测表头行。读前几行,找到包含关键列名(比如"日期""金额""编号")的那一行作为表头。代码稍复杂但鲁棒性强:
def find_header_row(file, keywords, max_scan=10): preview = pd.read_excel(file, header=None, nrows=max_scan) for i, row in preview.iterrows(): hits = sum(1 for kw in keywords if any(kw in str(v) for v in row.values)) if hits >= len(keywords) * 0.6: return i return 0这段逻辑的意思是:扫描前10行,哪一行里命中的关键词数量超过阈值(这里设60%),就认为它是表头。阈值别设100%,因为表头里可能夹杂空列或无关列。
策略三:统一重命名。不管原表头长什么样,读进来后按位置或按映射统一改成标准列名。适合列顺序固定但列名写法混乱的情况。
实际项目中我通常组合使用:先用策略二探测,再用策略三做列名标准化。这样即使某个文件的表头写法变了,也不至于整批失败。
2.3 列名对齐与数据类型陷阱
pandas的concat按列名对齐,这个机制是双刃剑。好处是列顺序不同也能正确拼接;坏处是列名只要差一个空格或全半角括号,就会被当成两列,结果总表里出现"金额"和"金额 "两列,各有一半数据是NaN。
解决办法是在拼接前做一次列名清洗:
def clean_columns(df): df.columns = (df.columns.astype(str) .str.strip() .str.replace(r"\s+", "", regex=True) .str.replace("(", "(", regex=False) .str.replace(")", ")", regex=False)) return df去空格、去全角括号,这两步能解决八成的列名不一致问题。更彻底的做法是维护一个映射字典,把各种别名统一到标准名,比如{"金额(元)": "金额", "金额": "金额", "价税合计": "金额"}。
数据类型是另一个隐形坑。Excel里看着是数字的列,读进来可能是object类型,原因通常是某几行混了文本(比如"暂无"、"-"、空格)。合并后做求和、排序就会出错。我的处理习惯是:合并完成后统一做一次类型转换,用pd.to_numeric(errors="coerce")把无法转换的变成NaN,再决定是填充还是剔除。这一步放在合并之后做,比逐个文件处理效率高得多。
2.4 内存与性能的现实考量
文件数量少(几十个以内)、单文件不大(几MB)的时候,上面的写法完全够用,几秒钟出结果。但如果遇到上百个文件、单文件几十万行的情况,就要注意内存了。pd.concat会把所有DataFrame一次性放进内存,数据量大时可能撑爆。
应对办法有两个。一是分批读取、分批拼接,用列表收集后一次性concat,而不是在循环里反复concat(后者是O(n²)的复杂度,越拼越慢)。二是如果只是做汇总统计,可以在读取时就用usecols只读需要的列,用dtype指定类型减少内存占用。我处理过一个两百多个文件的项目,最初在循环里concat,跑了快十分钟,改成先收集列表再一次性concat,时间降到二十秒出头,差别非常明显。
3. 完整实操流程与核心代码实现
3.1 环境准备与依赖安装
先把环境搭好。建议用虚拟环境隔离,避免和系统里其他项目的库版本打架:
python -m venv excel_merge_env # Windows激活 excel_merge_env\Scripts\activate # macOS/Linux激活 source excel_merge_env/bin/activate pip install pandas openpyxl xlrd装完可以用下面这行验证版本,心里有个数:
import pandas as pd, openpyxl, xlrd print(pd.__version__, openpyxl.__version__, xlrd.__version__)pandas建议2.0以上,openpyxl建议3.1以上。xlrd只在需要读.xls时用得上,如果确定全是xlsx可以不装。
3.2 完整可运行脚本
下面这份脚本是我在实际项目中反复打磨后的版本,涵盖了格式过滤、表头探测、列名清洗、来源标记、分批拼接、异常记录几个关键环节。你可以直接复制,改一下文件夹路径和关键词就能跑。
import pandas as pd from pathlib import Path import traceback # ============ 配置区 ============ FOLDER = Path(r"D:\报表数据\2024Q3") OUTPUT = FOLDER / "合并结果.xlsx" HEADER_KEYWORDS = ["日期", "金额", "编号"] # 用于探测表头 MAX_SCAN_ROWS = 10 # ================================ def find_header_row(file, keywords, max_scan=10): try: preview = pd.read_excel(file, header=None, nrows=max_scan) except Exception: return 0 for i, row in preview.iterrows(): hits = sum(1 for kw in keywords if any(kw in str(v) for v in row.values)) if hits >= max(1, len(keywords) * 0.6): return i return 0 def clean_columns(df): df.columns = (df.columns.astype(str) .str.strip() .str.replace(r"\s+", "", regex=True) .str.replace("(", "(", regex=False) .str.replace(")", ")", regex=False)) return df def read_one(file): engine = "xlrd" if file.suffix.lower() == ".xls" else "openpyxl" header_row = find_header_row(file, HEADER_KEYWORDS, MAX_SCAN_ROWS) df = pd.read_excel(file, header=header_row, engine=engine) df = clean_columns(df) df["来源文件"] = file.name return df def main(): files = [f for f in FOLDER.iterdir() if f.suffix.lower() in (".xlsx", ".xls", ".xlsm") and not f.name.startswith("~$")] print(f"共发现 {len(files)} 个待合并文件") frames, errors = [], [] for f in files: try: df = read_one(f) frames.append(df) print(f" 已读取 {f.name},{len(df)} 行") except Exception as e: errors.append((f.name, str(e))) print(f" 读取失败 {f.name}: {e}") if not frames: print("没有可合并的数据") return merged = pd.concat(frames, ignore_index=True, sort=False) merged.to_excel(OUTPUT, index=False) print(f"合并完成,共 {len(merged)} 行,输出到 {OUTPUT}") if errors: print("\n以下文件读取失败:") for name, msg in errors: print(f" - {name}: {msg}") if __name__ == "__main__": main()这段代码有几个设计上的考量值得说明。ignore_index=True让合并后的行号重新连续,避免出现重复索引;sort=False保留列出现的原始顺序,不按字母排序,读起来更符合直觉。异常用try包住并记录到errors列表,而不是让一个坏文件中断整批任务——这一点在处理真实数据时特别重要,你永远不知道哪个文件是别人用WPS另存过的。
3.3 参数选择背后的计算逻辑
配置区那几个参数不是随便设的。MAX_SCAN_ROWS=10的依据是:绝大多数报表的标题说明不会超过10行,扫太多行反而可能误判(比如数据区某行恰好也包含关键词)。len(keywords) * 0.6这个阈值,假设你设了3个关键词,命中2个就算表头,容忍一个关键词因为写法差异没匹配上。如果你设了5个关键词,命中3个即可,容错空间更大。
关键词的选择也有讲究。要选那些在表头里几乎必然出现、但在数据区很少出现的词。"日期""金额""编号"这类就合适,因为数据区里"日期"列下面是具体日期值,不会出现"日期"这两个字。反过来,如果你选"合计"当关键词,数据区最后一行往往就有"合计",容易误判。这个细节是我踩过坑之后才想明白的。
3.4 输出与后续处理
脚本默认输出xlsx。如果数据量超过一百万行,xlsx单表上限是1048576行,会截断,这时候要改成输出csv或者分表。输出csv用merged.to_csv(OUTPUT.with_suffix(".csv"), index=False, encoding="utf-8-sig"),注意utf-8-sig这个编码,不加的话用Excel打开中文会乱码,这是很多人反馈"导出的csv打开是乱码"的根本原因。
合并完成后,我一般还会做两件事:一是用merged.info()和merged.head()快速检查结构和前几行,确认列对齐没问题;二是用merged["来源文件"].value_counts()核对每个文件贡献的行数,和原始文件行数对一下,防止某个文件被漏读或重复读。这两步花不了一分钟,但能挡掉大部分低级错误。
4. 常见问题排查与避坑经验实录
4.1 报错速查表
实际操作中遇到的问题,八成集中在下面这几类。我整理成表格,方便对照排查。
| 报错或现象 | 可能原因 | 解决方向 |
|---|---|---|
| File is not a zip file | 读到了~$临时文件,或文件损坏 | 过滤~$开头文件,确认文件能正常打开 |
| xlrd.biffh.XLRDError | 用xlrd读了xlsx | 指定engine="openpyxl" |
| 合并后列数翻倍、大量NaN | 列名有空格或全半角差异 | 拼接前做列名清洗 |
| 数字列变成object无法求和 | 混入文本值 | to_numeric(errors="coerce") |
| 表头错位、第一行是标题 | 表头不在第一行 | 用skiprows或动态探测 |
| 内存溢出、程序卡死 | 文件过多过大,循环内concat | 收集列表后一次性concat |
| 中文乱码(输出csv) | 编码问题 | encoding="utf-8-sig" |
4.2 三个真实踩坑记录
第一个坑是隐藏的工作表。有些Excel文件里不止一个sheet,pd.read_excel默认只读第一个。如果你的数据在第二个sheet里,读出来就是空的或者错的。解决办法是明确指定sheet_name,或者用sheet_name=None读出所有sheet再判断。我现在的习惯是读之前先用openpyxl的wb.sheetnames看一眼,心里有底。
第二个坑是合并单元格。Excel里为了好看经常把表头合并,pandas读进来后,合并区域只有左上角有值,其余是NaN。这种表头清洗起来很麻烦。我的处理方式是:如果合并单元格出现在表头,读的时候用header=None把原始结构读出来,然后手动用ffill横向填充,再拼成列名。这一步比较绕,但如果你的源文件是人工做的报表,几乎躲不掉。
第三个坑是日期格式混乱。同一个"日期"列,有的文件是2024-01-05,有的是2024/1/5,有的是Excel序列号45296。pandas读进来可能是datetime、可能是字符串、可能是整数。合并后这一列就是个大杂烩。我的做法是合并后统一用pd.to_datetime(merged["日期"], errors="coerce")转换,转换失败的单独拎出来人工看。序列号那种情况,to_datetime配合unit="D", origin="1899-12-30"能处理,但更稳妥的是在读取阶段就用dtype=str读成文本再统一解析。
4.3 让脚本更稳的几个习惯
跑通一次不难,难的是让它长期稳定地跑。我总结了几个习惯,都是血泪教训换来的。
第一,永远保留来源文件列。合并后的总表如果出了问题,没有来源列你根本不知道是哪批数据错了。加了这一列,出问题能立刻定位到具体文件。
第二,先小批量试跑。正式合并前,先拿三五个文件跑一遍,看看列对齐、表头识别是否正常,确认无误再全量跑。全量跑完发现错了重来,时间成本高得多。
第三,输出文件带时间戳。OUTPUT = FOLDER / f"合并结果_{pd.Timestamp.now():%Y%m%d_%H%M}.xlsx",这样每次跑都生成新文件,不会覆盖上一次的结果,出问题还能对比。
第四,把配置和逻辑分离。文件夹路径、关键词、输出名这些放在文件顶部的配置区,逻辑代码不动。下次换个项目,只改配置区几行就行,不用在几百行代码里找哪里要改。这个习惯让我复用同一份脚本处理过好几个完全不同的业务场景。
4.4 什么时候该放弃脚本改用别的方案
说句实在话,不是所有场景都适合写脚本。如果你的文件只有两三个、合并一次就再也不用了,手工复制粘贴可能更快,写脚本反而是过度设计。如果数据需要复杂的清洗规则、需要人工判断每一行的归属,脚本也帮不上太多忙。
脚本真正发挥价值的地方是周期性、重复性、规则明确的合并任务。比如每周汇总各门店的销售表、每月合并各部门的报销单、每学期整合各班级的成绩表。这类任务规则固定、文件数量多、频率高,脚本的投入产出比最高。判断标准很简单:如果这件事你一年要做超过五次,且每次规则都一样,那就值得写成脚本。
另外,如果你的数据源本身就在数据库或者在线表格里,那更应该直接从数据源拉取,而不是先导出成Excel再合并。Excel合并本质上是"数据散落在文件里"这个现状的补救措施,能从根本上规范数据流转当然更好。但在现实工作中,文件散落是常态,这套方法就是应对常态的实用工具。
我在实际使用中最大的体会是:这类脚本的价值不在于技术多高深,而在于它把一件烦人的事变成了点一下按钮的事。写第一版可能花一两个小时,但后面每次省下的时间和避免的出错,早就把这点投入赚回来了。而且写的过程中对pandas的read_excel、concat、列操作会越来越熟,这些技能在别的数据处理场景里同样用得上,算是顺带的收获。