我处理过的最夸张的一次需求,是帮朋友合并三个月的门店日报。三百多个Excel文件,文件名还是"日报-3月(1).xlsx""日报-3月(2).xlsx"这种毫无规律的格式,手工复制粘贴加调整格式,至少熬两个通宵。用Python批量处理Excel和CSV文件,脚本十分钟跑完,还包括了清洗、去重和汇总统计。这篇文章要聊的,就是用Python批量处理Excel和CSV这件事——不是学院派的理论,而是我实际工作中反复使用的套路。
这篇文章适合谁?手里有成堆Excel/CSV文件需要合并、清洗、转换格式的运营、财务、数据分析岗的同学;每天跟数据报表打交道但不想靠手工点鼠标的重复劳动者;以及刚学Python没多久,想找个真实场景练手的人。读完你应该能自己搭一套"读文件→批量处理→写结果"的流程,碰到真实需求直接改改就能用。
1. 为什么这件"体力活"值得用Python重新做一遍
先别急着敲代码。我见过太多人,一上来就问"用哪个库读Excel",结果装了一堆东西还是不知道怎么用。问题的根源在于:没搞清楚批量处理的本质到底是什么。
所谓批量处理,核心就三件事:批量发现文件、批量执行同一套操作、批量写回结果。手工做的时候,一次处理一个文件;用Python做的时候,你只需要让程序知道"文件在哪、长什么样、要做什么操作",剩下的交给循环体就行。这背后的价值不仅是省时间,更是把"业务规则"变成了可复现的代码。人工处理一份报表时脑子里的判断标准是模糊的,脚本文档化的规则是精确的。
为什么不用Excel自带的功能?Excel本身确实有"合并工作簿""跨表引用"这类能力,但在文件数量上百、列结构不统一、还要附带清洗规则的场景下,Excel的操作路径非常笨拙——录宏也好,Power Query也好,都有各自的学习曲线和兼容性限制。更现实的问题是:大多数需要批量的场景,文件格式并不规整。有的Excel带合并单元格,有的CSV是GBK编码,有的列名在一个文件里叫"日期"、在另一个文件里叫"时间"。这种差异,Excel自带功能很难优雅处理,而Python配合pandas,可以把"处理逻辑"和"文件来源"彻底解耦。
为什么不用VBA?VBA确实能做很多事情,但它绑定在Office环境里,离开Excel就跑不了;同事换了WPS,宏还经常不兼容。Python脚本可以脱离Office运行,还能和后续的数据库导入、报表生成、邮件发送流程无缝衔接。再直白一点,VBA的生态已经基本冻结,而pandas、openpyxl这些库背后是庞大的开源社区,遇到问题搜一下几乎都有答案。
为什么不用PHP?有人搜"excel批量处理php",我只能说PHP处理Excel的生态相对薄弱,而且PHP更适合做服务端Web应用,做本地的数据处理脚本,门槛和手感都不如Python。Python在这件事上的优势是可以组合出一套极简工具箱:pandas专注表格数据处理,openpyxl专注Excel文件读写,glob负责找文件,三个组合基本覆盖了80%以上的日常工作场景。
2. 开工前的准备:环境安装、依赖选型和文件纪律
这一步没什么高深的,但很多人就是在这里翻车的。
2.1 环境与依赖
到Python官网下载安装包,一路下一步,记得勾选"Add Python to PATH"。如果已经装了Python但命令行输入python没反应,大概率就是PATH没配上。新手不建议一上来就折腾虚拟环境,装个Anaconda或者直接用系统Python都行,关键是先把pandas用起来。安装依赖只需要一条命令:
pip install pandas openpyxlpandas负责表格数据的读取、清洗、合并;openpyxl是pandas读写Excel的后端引擎。如果你还需要精细控制Excel样式,后面可以再装xlsxwriter;需要操作图片、图表的话,openpyxl也有对应能力,但日常批量处理这两个库够了。
2.2 文件纪律
文件纪律是很多人忽略的关键点。我的习惯是:每个批量任务建一个工作目录,目录下分input和output两个子文件夹,脚本放在根目录。输入文件统一放input,处理后输出的结果全部进output。这样做的好处有三个:脚本里的路径全部是相对路径,换一台机器不会报错;input和output分开,哪怕处理逻辑写错,也不会污染原始数据;排查问题时,一眼就知道哪些是源文件、哪些是产物。
2.3 动手前先抽样看文件
还有一个容易被忽略的动作:动手写代码之前,先抽样看三到五个文件。用文本编辑器打开CSV,用Excel打开一个工作簿,搞清楚三件事——表头在第几行、每列是什么类型、文件编码是什么。
CSV文件用记事本看:如果中文乱码,说明大概率是GBK/GB2312编码;不乱码就多半是UTF-8。Excel文件则要看有没有多Sheet、有没有合并单元格、有没有"金额"列带着¥符号和千分位逗号这种脏数据。编码问题值得单独说:中文环境下,老系统导出的CSV大多是GBK,新系统多为UTF-8。pandas读CSV默认编码是UTF-8,碰到GBK文件会直接报UnicodeDecodeError。解决办法是read_csv里指定encoding='gbk',或者更稳妥地用chardet检测编码。我实际工作的习惯是:优先问生成文件的人是什么编码;问不到就先试gbk,不行换utf-8,这两种能覆盖90%的情况。
3. 读文件的核心姿势:pandas如何拿捏Excel与CSV
读一个文件谁都会,读上百个文件就有门道了。
3.1 关键读写参数
先看最基础的读取。读CSV:
import pandas as pd df = pd.read_csv('input/2023年1月.csv', encoding='utf-8')读Excel:
df = pd.read_excel('input/门店日报.xlsx', sheet_name='Sheet1')真正批量处理时,至少还要掌握几个关键参数。
第一个是usecols(列筛选)。我见过太多人把整个Excel读进来,里面一半是无关的备注和杂项,内存白白浪费。批量处理时建议显式指定需要的列:
df = pd.read_excel('input/门店日报.xlsx', usecols=['门店', '日期', '销售额'])第二个是dtype(数据类型)。这是最容易踩坑的地方。Excel里看到的"00123",读进pandas可能变成整数123,或者因为混入空值被识别成float的123.0。像手机号、身份证号、工号这类需要保持前导零的字段,必须在读取时就强制指定为字符串:
df = pd.read_csv('input/员工信息.csv', dtype={'工号': str, '手机号': str})第三个是header(表头位置)。有些Excel文件第一行是标题,第二行才是真正的表头;有些报表顶部还有几行说明文字。遇到这种文件,用header=2或者skiprows=1处理:
df = pd.read_excel('input/报表.xlsx', header=2)第四个是sheet_name(工作表名)。如果一份Excel里有多个Sheet且结构不同,可以遍历sheet_name把它们各自读到独立的DataFrame里:
xl = pd.ExcelFile('input/汇总表.xlsx') for sheet in xl.sheet_names: df = pd.read_excel(xl, sheet_name=sheet) print(sheet, df.shape)3.2 用glob批量发现文件
接下来是批量读取的核心——配合glob发现文件。glob是Python标准库,用来按通配符匹配文件名:
import glob files = glob.glob('input/*.xlsx') print(files)这段代码会把input文件夹下所有.xlsx文件的路径列出来。想只处理特定前缀的文件,改成glob.glob('input/日报*.xlsx')就行。拿到文件列表以后,循环读取:
all_data = [] for f in files: df = pd.read_excel(f, usecols=['门店', '日期', '销售额']) all_data.append(df) merged = pd.concat(all_data, ignore_index=True)pd.concat负责把多个DataFrame纵向拼接成一个大DataFrame,ignore_index=True表示重新生成连续索引,避免各份文件原来的行号混在一起。
3.3 合并前的列名统一
这里有一个我反复踩到的现实问题:各文件的列名不统一。3月文件叫"门店",4月叫"店铺",合并之前不处理就会产生两列。我的处理方式是在循环里加一个标准列名映射表:
column_map = {'门店': '门店', '店铺': '门店', '分店': '门店'} df = df.rename(columns=column_map)这样不管源文件里叫什么都无所谓,进入合并结果时都是统一的"门店"。
4. 批量处理的经典动作:合并、清洗、查找与转换
文件读完,重头戏才开始。批量处理的价值不在于"能读",而在于"读完之后能自动完成一套有业务含义的操作"。
4.1 合并:纵向concat与横向merge
前面用pd.concat演示了多个同结构文件的纵向合并。还有一种场景是横向合并:两个文件有共同的键(比如门店ID),需要把各自的字段拼成更宽的表。这就要用merge:
df_shop = pd.read_excel('input/门店信息.xlsx') df_sales = pd.read_excel('input/销售数据.xlsx') result = df_sales.merge(df_shop, on='门店ID', how='left')merge相当于Excel里的VLOOKUP,但比VLOOKUP好用得多——可以指定多个连接键(on参数传列表),可以选择保留方式(how='left'保留左边全部记录,how='inner'只保留两边都有的)。实际合并前,先确认连接键的类型一致。一个文件里门店ID是字符串"001",另一个是整数1,合并结果会留下一堆NaN。
4.2 清洗:去重、补空、格式还原
清洗这个词听起来抽象,实际上就是处理那几类脏数据:空值、重复值、错误格式、文本中的空格和符号。
去重最常见:
df = df.drop_duplicates(subset=['订单号'])空值填充按需处理:
df['备注'] = df['备注'].fillna('无') df = df.dropna(subset=['销售额'])销售额带符号的问题也很容易碰到。Excel里"¥1,234.56"这种格式,pandas读进来是字符串,不能直接计算。需要先去掉千分位逗号和货币符号再转float:
df['销售额'] = df['销售额'].str.replace('¥', '', regex=False) df['销售额'] = df['销售额'].str.replace(',', '', regex=False) df['销售额'] = pd.to_numeric(df['销售额'], errors='coerce')errors='coerce'很关键:如果某个单元格无法转成数字,pandas不会报错中断,而是把它变成NaN。事后排查就很方便。
日期列的处理同样高频。Excel里的日期有时候是"2023/1/5",有时候是"2023年1月5日",还有时候是真正的datetime单元格。统一转换成标准格式:
df['日期'] = pd.to_datetime(df['日期'], format='%Y/%m/%d', errors='coerce')如果不想指定格式,直接pd.to_datetime(df['日期'])也可以,pandas会自动推断多种常见格式。
4.3 查找与转换:从contains到pivot_table
"python查找excel中字符串"这种需求,本质上就是contains做条件筛选。比如找出所有备注里包含"退款"的订单:
refunds = df[df['备注'].str.contains('退款', na=False)]na=False保证备注为空时不会报错,而是被过滤掉。如果要按多个关键词筛选,可以用正则:
keyword = '退款|退货|取消' bad_orders = df[df['备注'].str.contains(keyword, na=False, regex=True)]转换动作也很常用。比如把明细表转成透视表格式:
pt = df.pivot_table(index='门店', columns='品类', values='销售额', aggfunc='sum', fill_value=0)一行代码,就能把"门店+品类+销售额"的长表转成"行是门店、列是品类"的宽表。这在做汇总分析时非常常用。
跨格式转换也经常遇到。很多人搜"markdown表格转换excel",说明文档里的表格要迁移到Excel。markdown表格本质上就是带|分隔的纯文本,用pandas处理非常合适:把markdown表格粘贴到文本文件里,读取时用分隔符'|':
df = pd.read_csv('input/table.md', sep='|', skipinitialspace=True) df = df.dropna(axis=1, how='all') # 去掉空列然后to_excel写出去就行。反过来,Excel转markdown表格也只靠简单字符串拼接。
5. 结果写回:快速导出与保留格式两条路线怎么选
处理完的数据要落盘,这里有两个完全不同的需求方向,很多新手混在一起。
5.1 快速导出路线
第一种是数据要能再次被程序读取,或者要导入数据库、发给别人继续加工。这种场景用CSV或标准Excel表格就够了,追求数据准确、体积小、兼容性好。用to_csv:
merged.to_csv('output/合并结果.csv', index=False, encoding='utf-8-sig')注意这个utf-8-sig。如果直接写utf-8,用Excel打开含中文的CSV文件时,大概率会乱码。utf-8-sig会在文件开头加一个BOM标记,Excel能正确识别成UTF-8编码,处理含中文的CSV时基本是必选项。to_excel则是:
merged.to_excel('output/合并结果.xlsx', index=False, sheet_name='汇总')5.2 保留格式路线
第二种是给领导看、要保留Excel原有格式的场景。这时候直接to_excel覆盖不行,因为pandas写出来的Excel没有格式。如果你的需求是在原有Excel基础上改动一些单元格、或者给结果加样式,就要用openpyxl。
比如只想给汇总表插入一列,同时不破坏原来的列宽、字体和颜色,可以这样:
from openpyxl import load_workbook wb = load_workbook('input/原始报表.xlsx') ws = wb.active ws['H1'] = '新增列' # 这里可以按业务逻辑填写H列的值 wb.save('output/带格式结果.xlsx')openpyxl的API比较底层,适合"精确控制单元格"的场景。如果只是要pandas输出时带一点简单的表头样式,xlsxwriter引擎会更顺手:
with pd.ExcelWriter('output/带样式.xlsx', engine='xlsxwriter') as writer: merged.to_excel(writer, sheet_name='汇总', index=False) workbook = writer.book worksheet = writer.sheets['汇总'] header_fmt = workbook.add_format({'bold': True, 'bg_color': '#D9E1F2'}) worksheet.set_row(0, None, header_fmt)5.3 多Sheet输出
多Sheet输出是高频需求。把多个DataFrame写进同一个Excel的不同工作表:
with pd.ExcelWriter('output/多表汇总.xlsx') as writer: summary.to_excel(writer, sheet_name='汇总', index=False) detail.to_excel(writer, sheet_name='明细', index=False) bad.to_excel(writer, sheet_name='异常单', index=False)一个文件搞定所有结果,领导和同事只需要下载一个附件。我做报表的习惯是,把最终文件分成"汇总页"和"异常页"两个Sheet——正常情况下只看汇总页,有问题才去翻异常页。
6. 一个完整实战:门店日报合并清理,从零到可用
原理讲了这么多,不如直接跑一个完整例子。这是我做过的真实需求的简化版:合并一个月的门店销售日报,做数据清洗,输出一个带汇总和多Sheet的结果文件。
6.1 需求拆解
运营每天发一份Excel,叫"店报-20230601.xlsx",里面是当天各门店各品类的销售明细。一个月30份,要合成一张总表,同时统计各门店总销售额,把异常数据单独列出来。
6.2 完整脚本
第一步,目录和文件准备。把30份日报都放进input文件夹,用glob匹配:
import pandas as pd import glob files = sorted(glob.glob('input/店报-*.xlsx'))第二步,循环读取并做统一的列名映射和清洗:
column_map = {'门店': '门店', '店名': '门店', '分店': '门店'} all_data = [] for f in files: df = pd.read_excel(f, usecols=['门店', '品类', '销售额', '订单数'], dtype={'订单数': int}) df = df.rename(columns=column_map) df['销售额'] = pd.to_numeric(df['销售额'], errors='coerce') df = df.dropna(subset=['销售额']) all_data.append(df)第三步,合并所有文件:
merged = pd.concat(all_data, ignore_index=True)第四步,识别异常数据。销售额为负数的退款记录、订单数为0但销售额不为0的记录:
negative_sales = merged[merged['销售额'] < 0] zero_order_sales = merged[(merged['订单数'] == 0) & (merged['销售额'] != 0)] bad_rows = pd.concat([negative_sales, zero_order_sales]).drop_duplicates()第五步,清洗后的正常数据用于汇总:
clean = merged.drop(index=bad_rows.index) summary = clean.groupby('门店', as_index=False).agg({'销售额': 'sum', '订单数': 'sum'})第六步,写回结果:
with pd.ExcelWriter('output/6月门店汇总.xlsx') as writer: summary.to_excel(writer, sheet_name='门店汇总', index=False) merged.to_excel(writer, sheet_name='全部明细', index=False) bad_rows.to_excel(writer, sheet_name='异常记录', index=False) print(f'共处理 {len(files)} 个文件,总记录数 {len(merged)},异常记录 {len(bad_rows)}')6.3 运行效果与细节说明
整个脚本不到四十行,跑一次不到三秒。以前人工处理30份日报,算上复制粘贴、公式核对、异常筛选,至少一个下午;现在每天下班前跑一遍脚本就能出数。更重要的是,脚本逻辑固定,不会像人手操作那样时好时坏,换个月份只需改一下文件名匹配规则。
补充一个真实细节:运营发的日报,有时候某天文件名是"店报-20230601.xlsx",有时候是"店报-20230601(1).xlsx"(同一天生成多份)。glob的*通配符能匹配到这些文件,但排序时要小心。我用了sorted()保证处理顺序按文件名排序,这样即使某天出现重复文件,顺序也是稳定的。
7. 最容易翻车的五个坑,以及绕坑习惯
最后分享一些我在批量处理里踩过的坑,每一个都是真实代价换来的。
7.1 编码问题:读写CSV的隐形杀手
除了前面提的UTF-8和GBK读取报错,写CSV时也有对应的坑——用utf-8写出的CSV在Excel里打开中文乱码。我的习惯是写入CSV永远用encoding='utf-8-sig'。另外,有些文件本身就是UTF-8 BOM编码,pandas读取时第一列列名会带上\ufeff字符,处理方法是read_csv时指定encoding='utf-8-sig',会自动去掉BOM。
7.2 前导零丢失
工号"00123"变成123,看起来是小事,但用这类字段做关联匹配时会完全匹配不上。对应策略就是前面说的dtype={'工号': str}。如果已经读进来了才发现,补救办法是:
df['工号'] = df['工号'].astype(str).str.zfill(5)zfill(5)表示补足至少5位,但需要先知道正确的位数。所以最好的办法还是在读取时就锁死类型。
7.3 空文件和全空Sheet
批量处理几十个文件时,偶尔会有一个文件只有表头没有数据。pd.concat空DataFrame通常没问题,但如果对这个空DataFrame做了某些操作(比如取第一行),就会报错或产生诡异结果。我的习惯是在循环里加一个判断:
if df.empty: print(f'警告:{f} 是空文件') continue7.4 内存问题
几百MB的CSV一次性pd.read_csv全部读进内存,笔记本可能会卡死。遇到大文件,可以分块读取:
chunks = pd.read_csv('input/超大文件.csv', chunksize=100000) result_parts = [] for chunk in chunks: result_parts.append(process(chunk)) result = pd.concat(result_parts)chunksize表示每次读取10万行,处理完再读下一批。实际经验是:文件超过200MB就建议分块处理,否则机器会风扇狂转。
7.5 路径问题
脚本里写input/xxx.csv,依赖的是"当前工作目录"。在Jupyter Notebook里跑,当前目录可能是Notebook所在目录;在命令行跑,可能是执行python时的目录。最稳妥的做法是用pathlib构造相对于脚本文件的路径:
from pathlib import Path BASE_DIR = Path(__file__).parent input_dir = BASE_DIR / 'input'用Path对象拼路径,在Windows和macOS/Linux上都能正常工作,不会踩斜杠方向不一致的坑。
8. 我长期做数据处理脚本沉淀的几个习惯
最后说几点我踩过多次坑之后沉淀下来的习惯,对刚开始写批量处理脚本的人应该有点用。
第一,数据操作之前永远留一份原始文件的备份。input和output分离的目录结构本身就是一种保障——脚本只读取input,只写入output,原始文件永远不会被覆盖。如果你确实需要修改原始文件,也先复制一份到backup目录。
第二,把处理逻辑封装成函数。我第一次写合并脚本时,所有代码都堆在顶层,能跑通,但换一个需求就要重写。后来养成习惯:把"读文件并清洗""合并去重""生成汇总"分别写成函数,每个函数只干一件事。这样不同批次的报表只需要调用同一个函数,传文件名就行。函数化的脚本还有一个好处:测试方便。对函数传入造好的小DataFrame,看输出是否符合预期,比跑整个脚本快得多。
第三,脚本里加打印进度。处理几十上百个文件时,干等着心里没底。我会在循环里加一个计数器,每处理10个文件打印一条进度;全部处理完再打印总记录数和异常记录数。跑完看日志,就知道哪些文件被跳过、哪些数据被清洗掉了。加一行print的成本极低,排查问题时收益非常大。
第四,先在小样本上测试。不管脚本看起来多正确,第一次跑的时候,我都会先只匹配前3个文件,确认输出没问题了,再放全部文件跑。这能避免最尴尬的情况:30份文件全部合并完,才发现有一份的列名映射错了,又要从头跑一遍。
第五,脚本要版本化。哪怕只是加个注释,保存时也换个带日期的文件名。我做过一次蠢事:改了一个脚本,结果改坏了,但原来的版本已经被覆盖,花了大半天时间重新调试。从那以后,我养成了命名带版本的习惯,比如merge_daily_report_v2.py。
一个人真正开始省时间,是从写第一个批量处理脚本开始的。刚开始可能写得很慢,一个简单的合并脚本要改半天,但同一个脚本第二次、第三次使用几乎零成本。我到现在还会在项目里翻旧脚本,复制里面的函数改一改就用——这就是长期积累的价值。
如果你手头正好有一堆Excel或CSV要处理,别急着复制粘贴了。先把文件放进input文件夹,装好pandas,用这篇文章里的套路跑通第一个合并脚本。跑通之后,你会回来感谢自己。