做数据分析的人,十个里有九个躲不开Excel文件。业务方交付数据常常是“把这个表给我处理一下”,而这个表就是Excel;你要交出去的结果,也常常被要求“导成一个Excel”。所以Pandas数据的输入与输出,最绕不开的就是Excel文件读写。这篇我就把read_excel和to_excel从参数到坑,从环境到实战,完整讲一遍。适合刚上手Pandas想系统弄清楚读写细节的人,也适合已经被Excel脏数据折磨到想撞墙的分析师。
老规矩,我不喜欢只贴代码然后说“你看就这样”。我会把每个关键参数背后的为什么、什么时候用、以及我踩过的坑一起讲清楚。看完之后,你手里的Excel文件至少能稳稳地读进来、干干净净地写出去。
1. 先搞清楚Pandas读写Excel的底层依赖
1.1 Excel文件在Pandas眼里到底是什么
这和很多人直觉不一样:Pandas本身并不会“解析Excel”。Excel文件不是纯文本,.xlsx本质上是一个zip压缩包,里面装着很多XML文件,分别描述工作表、单元格、样式、公式等等。.xls则是另一种老的二进制格式,规则更麻烦。
Pandas没有打算自己实现这套解析逻辑,因为已经有更专业的库在做这件事。它只提供一个统一接口,背后调用第三方引擎去读取或写入。这就是为什么你只pip install pandas之后,直接读Excel常常报错,提示缺openpyxl或者xlrd。不是Pandas坏掉了,而是它依赖的“翻译官”没装。
这个依赖关系特别重要,因为很多莫名其妙的问题都出在引擎选错上。比如.xls和.xlsx对应库不同,同一个引擎在不同版本支持范围还会变。先把这个底摸清楚,后面排错才有方向。
1.2 引擎选型与最小环境安装
一套能正常读写Excel的最小环境,至少要装这几个包:
pandas:主菜。openpyxl:负责读写.xlsx格式,是目前Pandas读写新格式Excel的主要引擎,写和读都能干。xlrd:负责读取老旧的.xls格式文件。xlsxwriter:只负责写入.xlsx,不负责读。如果你有特殊写需求,比如写公式、条件格式,可以考虑它。
安装命令很简单:
pip install pandas openpyxl xlrd xlsxwriter如果你想控制版本,我建议至少保证:
pandas>=1.3 openpyxl>=3.0 xlrd>=2.0但这里有一个极其经典的版本坑:xlrd 2.0以后,官方砍掉了对.xlsx的支持,只保留老格式.xls的读取能力。所以如果你用xlrd去读.xlsx,会直接报错。别装一个老掉牙的xlrd版本去凑合,正确做法是让.xls走xlrd,让.xlsx走openpyxl。
Pandas在没有明确指定引擎时会根据文件扩展名自动选择。一般规则是:
| 文件格式 | 读取引擎 | 写入引擎 |
|---|---|---|
.xlsx | openpyxl | openpyxl或xlsxwriter |
.xls | xlrd | 需要xlwt(已停止维护,尽量别写老格式) |
.ods | odf | odf |
真正上手的时候,大部分场景都是处理.xlsx,所以你只要确保openpyxl装好,就解决了80%的问题。文件打不开时如果报错提示“Missing optional dependency 'openpyxl'”,不需要怀疑人生,补装它就行。
2. 读取Excel:用熟read_excel的核心参数
2.1 最基础的读法,以及表头和索引的坑
读取一个Excel文件,最基础的写法是:
import pandas as pd df = pd.read_excel("data.xlsx")如果不加任何参数,Pandas默认帮你做三件事:读取第一个工作表、把第一行当作列名、自动生成一列从0开始的索引。听起来挺正常,但实际项目中通常第一行不一定是表头,或者第一列本来应该是索引但被当成了普通数据。
常见的几个调整参数:
# 文件没有表头,所有行都是数据 df = pd.read_excel("data.xlsx", header=None) # 第一列是行索引,不要当成普通列 df = pd.read_excel("data.xlsx", index_col=0) # 前几行是说明文字,跳过它们再找表头 df = pd.read_excel("data.xlsx", skiprows=2)index_col=0的意思是把第一列作为DataFrame的索引,这通常出现在导出时带了索引列,或者Excel里本身有一列ID。这个一定要养成检查习惯,不然后面聚合或者拼接容易出现重复索引。
skiprows的坑在于它和header会互相影响。比如你跳过两行说明文字后,第三行才是表头,那写skiprows=2其实已经把原先的表头行也当成“跳过”了,Pandas会把原本的第三行当作表头。这个行为在多数情况下是正确的,但如果你只想跳过说明行而保留原表头,就得按实际行号算清楚。我的建议是先用Excel打开看一个大概结构,再决定参数,别靠猜。
2.2 控制读取范围:sheet、列、行
Excel文件最容易让人头疼的是一个工作簿里塞了好几个表。想只读其中一部分,依赖三个关键参数。
sheet_name:指定工作表。可以是工作表名称字符串,也可以是位置数字,比如0表示第一个sheet。如果传列表,返回的是一个dict,key是sheet名,value是对应DataFrame。
# 按名称读取 df = pd.read_excel("data.xlsx", sheet_name="订单明细") # 读取多个sheet,返回字典 dfs = pd.read_excel("data.xlsx", sheet_name=["订单明细", "退货明细"]) print(dfs["订单明细"]) # 读取所有sheet all_dfs = pd.read_excel("data.xlsx", sheet_name=None)关于sheet_name=None的使用场景:当你不确定工作簿里有哪些sheet,或者想一口气全部读进来时很好用。但要注意,如果各sheet结构不一样,后面还要单独清洗,别指望直接concat。
usecols:指定要读哪些列。它支持好几类写法,最实用的是直接传列名列表,或者传Excel列字母区间。
# 只读指定的三列 df = pd.read_excel("data.xlsx", usecols=["姓名", "部门", "工资"]) # 用列字母区间,A到C df = pd.read_excel("data.xlsx", usecols="A:C")这个参数在大表场景下非常关键。很多Excel文件动辄几十上百列,实际分析只用其中几列,不限制列数的话,内存和时间都浪费在读没用的数据上。
nrows:只读取前N行。配合skiprows可以做抽样预览,或者跳过表头后读取前面一部分数据来做格式探测。
# 跳过头两行后,只读10行 df = pd.read_excel("data.xlsx", skiprows=2, nrows=10)注意:nrows是读取行数上限,不是从第N行读到第M行。如果你需要读取中间的某一段,办法是skiprows加nrows组合,但这样会把前段全部读一遍再丢弃,性能上不一定划算。Excel格式本身就不支持随机跳读,这种场景比较少。
2.3 数据类型控制:dtype、parse_dates、na_values
Excel单元格里的格式是很“虚”的东西。一个单元格显示为日期,底层可能存储的是日期对象,也可能是一串文本,甚至是一个数字序列号。一个单元格看起来是数字,也可能因为混入了空格或逗号,被Pandas读成字符串。所以读Excel后第一件事永远是检查dtypes。
dtype参数可以强制指定列的类型:
df = pd.read_excel( "data.xlsx", dtype={"订单号": str, "金额": float} )订单号这类ID列最好显式指定为字符串,不然很长一串数字可能被读成科学计数法,后面匹配的时候对不上。而金额列如果本身有缺失或文本,dtype=float可能会报错,所以有时候我们宁愿先读进来再用pd.to_numeric(..., errors="coerce")处理。
日期解析是另一个重头戏。Excel里最让人头疼的日期形态有三种:
- 真正的Excel日期单元格,openpyxl读出来是
datetime。 - 单元格里是一段标准文本,比如
"2024-05-18 14:30",需要用parse_dates处理。 - 单元格里是纯数字,比如
45064,这是Excel日期序列号,需要特殊转换。
parse_dates可以接一个列名列表,把指定列尝试解析成日期:
df = pd.read_excel( "data.xlsx", parse_dates=["下单时间"] )如果日期列同时包含多种格式,或者时间戳和日期分开两列,你还可以传一个组合列名列表,比如parse_dates={"下单时间": ["下单日期", "下单时刻"]},要求Pandas把两列合并解析成一个时间列。这个不是特别常用,但遇到时很好使。
缺失值的处理也不能靠默认。实际Excel里经常用"-"、"NULL"、"#N/A"表示空值。Pandas有自己的默认缺失值列表,但默认不包括"-"这种。你需要用na_values手动补充:
df = pd.read_excel( "data.xlsx", na_values=["-", "NULL", "#N/A"] )如果文件里大量使用其他符号表示空值,可以在na_values里传一个列表或者字典。经常有业务数据把0也当成空值,但直接加na_values=["-", 0]会把真正的0也变NaN,非常危险。我建议先看数据分布,再决定是否把某个数字当成缺失值。
3. 写入Excel:用熟to_excel和ExcelWriter
3.1 一行代码导出,但记得关掉Index
写入Excel的入门用法是:
df.to_excel("output.xlsx")这句代码确实能跑,但我几乎从来不会这么直接写,因为它默认会把DataFrame的索引也写进文件,第一列会多出一列无名的索引列。如果你再把这文件读回来,就会多出一列“Unnamed: 0”,非常烦人。
所以导出时第一件事记熟:
df.to_excel("output.xlsx", index=False)index=False的意思是“不把索引写入文件”。如果你的索引本身是有业务含义的列,比如日期或ID,那别急着关掉。你可以先用df.reset_index()把它变成普通列,再导出,这样列名和内容都清晰。
除了index,还有一个很实用的参数是float_format:
df.to_excel("output.xlsx", index=False, float_format="%.2f")这能保证浮点列保留两位小数。很多人说“我明明在DataFrame里是两位小数,导出来怎么变成一堆小数位”,原因就是浮点数的底层存储问题。float_format是导出时最省事的格式化手段。
columns参数可以只导出DataFrame中的部分列:
df.to_excel("output.xlsx", index=False, columns=["订单号", "金额", "状态"])适合一个DataFrame里有很多中间计算列,但最终交付只需要少数几列的场景。
3.2 多Sheet写入:ExcelWriter才是正解
如果只写一个sheet,df.to_excel("output.xlsx")没问题。但如果你想在一个文件里写多个sheet,千万别这样写:
df1.to_excel("output.xlsx", sheet_name="汇总") df2.to_excel("output.xlsx", sheet_name="明细")这样第二次写入会把整个文件覆盖掉,最后只剩下df2的结果。正确做法是使用pd.ExcelWriter:
with pd.ExcelWriter("report.xlsx") as writer: df1.to_excel(writer, sheet_name="汇总", index=False) df2.to_excel(writer, sheet_name="明细", index=False)ExcelWriter的with写法会自动帮你提交并关闭文件,不用手动处理。在同一个writer下,多个DataFrame可以写到不同sheet,互相不覆盖。
还有一种情况:你想往一个已经存在的Excel文件里追加一个新sheet。Pandas从版本1.3以后支持mode="a":
with pd.ExcelWriter("report.xlsx", mode="a", if_sheet_exists="replace") as writer: df3.to_excel(writer, sheet_name="新增表", index=False)其中if_sheet_exists可以设置如果同名sheet已经存在时的处理策略:"replace"覆盖,"new"新建一个带数字后缀的sheet。注意追加时引擎要和原文件格式匹配,一般都要显式指定engine="openpyxl",因为追加写入只有openpyxl支持得比较好。
3.3 让导出的Excel更美观:列宽、冻结窗格、颜色
Pandas本身只负责“把数据写进格子”,不负责排版。但实际交付报表时,如果文件打开列宽都挤在一起,观感会差很多。好在ExcelWriter可以拿到底层的openpyxl对象,二次加工很灵活。
with pd.ExcelWriter("report.xlsx", engine="openpyxl") as writer: df.to_excel(writer, sheet_name="汇总", index=False) ws = writer.sheets["汇总"] ws.column_dimensions["A"].width = 12 ws.column_dimensions["B"].width = 20 ws.freeze_panes = "A2"freeze_panes是冻结窗格,"A2"表示把第1行冻结,滚动时始终能看到表头。如果你同时想出“第一列也冻结”,就设置为"B2"。
还可以设置单元格格式,比如把表头加粗:
from openpyxl.styles import Font with pd.ExcelWriter("report.xlsx", engine="openpyxl") as writer: df.to_excel(writer, sheet_name="汇总", index=False) ws = writer.sheets["汇总"] ws.column_dimensions["A"].width = 20 for cell in ws[1]: cell.font = Font(bold=True)这里ws[1]是工作表第一行,也就是表头。如果你不想做太复杂的样式,用openpyxl动手改,比在Pandas层硬凑方便得多。
4. 实战:一份脏Excel从清洗到报表输出的完整流程
4.1 场景是这样的
假设你拿到一份“销售明细_2024.xlsx”,里面只有一个sheet“原始数据”。数据大概长这样:
- 下单时间:有的单元格是真正的日期时间,有的是文本
"2024/1/5 9:30"。 - 金额:有的数值是
1234.56,有的是带千分位的"1,234.56"。 - 缺失值:手工填了
"-"。 - 订单号:是一长串数字,必须按字符串处理。
目标很明确:把这份脏数据清洗成规整的DataFrame,然后按月份汇总销售额,输出一个包含“月度汇总”和“清洗明细”两个sheet的报表文件。
这种场景太常见了,常见到几乎每次做项目都会遇到。下面是我习惯的写法。
4.2 代码和关键点解析
import pandas as pd # 读取原始数据 df = pd.read_excel( "销售明细_2024.xlsx", sheet_name="原始数据", dtype={"订单号": str}, thousands=",", na_values=["-"], parse_dates=["下单时间"], ) # 如果金额列仍有非数值内容,强制转换 df["金额"] = pd.to_numeric(df["金额"], errors="coerce") # 把时间列标准化成字符串月份 df["月份"] = df["下单时间"].dt.strftime("%Y-%m") # 按月汇总销售额 report = df.groupby("月份", as_index=False)["金额"].sum() report = report.rename(columns={"金额": "销售额"}) # 输出报表 with pd.ExcelWriter("销售报表_2024.xlsx") as writer: df.to_excel(writer, sheet_name="清洗明细", index=False) report.to_excel(writer, sheet_name="月度汇总", index=False)几个容易忽略的细节:
thousands=","解决的是千分位字符串问题。如果金额列里混了逗号,不写这个参数,Pandas会把它当成字符串,后面求和全乱。dtype={"订单号": str}保证订单号读进来是字符串,避免长数字被科学计数法。na_values=["-"]把业务中手工填的短横线转成NaN。parse_dates直接解析下单时间。但这里要注意,如果文件里既有真正的日期时间对象,又有文本日期,parse_dates大多数情况下能处理,但偶尔还是会解析失败。稳妥的做法是先检查,再决定要不要用errors="coerce"。
4.3 输出以后一定要读回来看一眼
很多人导出完文件就完事了,觉得“反正代码没报错”。但Excel类文件最坑的地方就在于,运行没报错不代表结果对。我强烈建议养成一个回读校验的习惯:
# 回读检查 rename_check = pd.read_excel("销售报表_2024.xlsx", sheet_name="月度汇总") detail_check = pd.read_excel( "销售报表_2024.xlsx", sheet_name="清洗明细", dtype={"订单号": str}, ) print(detail_check.shape) print(detail_check.dtypes) print(report.head())这一步至少能确认三件事:
- 行数对不对,有没有因为错误的缺失值处理把有效行变成NaN然后被drop掉。
- 列类型对不对,订单号有没有变回数字或科学计数法。
- 汇总结果和业务口径是否一致,比如月份数量是否符合预期。
我见过太多人因为某个列在导出的Excel里被Excel软件自动改成日期格式,导致回读后数据完全变样。回读校验不能保证所有细节都对,但至少能拦住大部分低级错误。
5. 常见报错和排查手册
5.1 引擎相关报错:装是装了,怎么还是报错
读Excel报错里最经典的一类,就是明明装了库,却还是在报引擎错。常见报错长这样:
ValueError: Excel file format cannot be determined, you must specify an engine manually.这通常不是你代码写错,而是文件扩展名和真实格式不匹配。比如一个文件实际内容是.xlsx,但后缀被人改成了.xls,或者反过来。Pandas拿不到可信的格式信息,只能让你手动指定引擎。
df = pd.read_excel("奇怪的文件.xls", engine="openpyxl")遇到这种文件,先别急着写死引擎,用Excel打开看看到底是什么格式。如果只是扩展名不对,可以重命名;如果不确定,可以用文件头判断。但最省事的方法是打开文件另存为正确的.xlsx,再给Pandas处理。
另一个高频报错:
ImportError: Missing optional dependency 'openpyxl'. Use pip or conda to install openpyxl.这个最简单,直接装缺的库。但还有一种隐蔽情况:报错里的库你明明装了,却仍然找不到,大概率是当前Python环境和Pandas不在同一个环境。用pip list确认一下,别装错了虚拟环境。
5.2 读出来的数据全是NaN或者类型不对
“我Excel里明明白白写着123,读出来变成NaN”这种事,每个做数据处理的人应该都经历过。排查思路一般按以下顺序:
- 是不是那一列里混了不可见字符,比如空格、换行符。
123旁边有个空格,会变成字符串,再与其他数字混合,整列类型变object。 - 是不是某些单元格里其实存的是公式,但公式结果为空,openpyxl默认读的是公式结果,如果缓存里没有结果,可能读到None。
- 是不是把自己定义的缺失值符号误加了。比如
na_values=["-", 0],然后真实的0也被当成缺失值。
类型不对的另一个经典场景是日期列。你明明看到2024-05-18,读进来却是数字45065,这是因为Excel底层存储日期用的是“从1900年1月1日开始的天数”。Pandas在读老格式或某些特殊引擎时,可能没有正确识别单元格的日期格式,把它当普通数字读了。
处理办法:
df["下单时间"] = pd.to_datetime(df["下单时间"], origin="1899-12-30", unit="D")这里unit="D"表示单位是天,origin取1899-12-30是为了对齐Excel的序列号规则。具体差多少天和Excel版本有关,但上面的组合是Excel日期序列号最常用的转换方式。
5.3 Excel文件太大,读得很慢怎么办
Excel本身不是为大数据设计的。.xlsx里即使只有20万行,读起来可能也要十几秒,和CSV完全不是一个量级。更大的问题是Pandas的read_excel没有read_csv里的chunksize参数,没法直接分块迭代读取。
如果只是想要快速看看数据,可以用nrows抽样:
df_sample = pd.read_excel("big.xlsx", nrows=5000)这样至少能先了解列名和大致内容。如果确实需要全量读取,又嫌慢,有几个优化思路:
- 用
usecols只保留分析的列,减少数据量。 - 用
dtype显式指定数值列,避免Pandas做类型推断开销。 - 考虑先把Excel转成CSV,再用
read_csv的chunksize分块处理。Excel转CSV可以用Excel本身,也可以用小工具批量处理。 - 如果是
.xlsx,也可以用openpyxl的read_only=True模式自己去迭代,但这就绕过了Pandas的接口,适合特别精通底层的人。
我个人的经验是:超过一定体量后的Excel文件,最好的处理方式不是硬用Pandas读,而是找源头导出CSV或者数据库。Excel作为交付格式没问题,但作为大数据的“传输格式”真的不合适。
5.4 写出的Excel日期、数字看着不对
写入时最常见的两个问题:一是日期变成了“一串数字”,二是浮点数小数位失控。
日期变成数字,通常是因为DataFrame里的日期类型是datetime64,但写出来后openpyxl把它当普通对象处理,没设置成Excel日期单元格格式。多数情况下to_excel会自动识别datetime类型,但在某些版本或引擎下会失效。解决办法是在写入前先把时间列转为Python原生的datetime.datetime:
df["下单时间"] = df["下单时间"].dt.to_pydatetime()浮点数小数位失控,前面提到过用float_format解决。如果还是不行,检查一下是不是列里有文本型数字,混合类型会导致整个列被Pandas判定为object,float_format根本不起作用。这时候先清洗列类型再导出。
6. 进阶玩法:批量合并Excel和模板化输出
6.1 批量读取一个文件夹下所有Excel
实际工作中经常要处理一堆格式相同的Excel文件,比如每个部门交一份表,每个月一个文件。手动read_excel再concat也能做,但循环写得不好会非常慢。
我的标准做法是:
from pathlib import Path import pandas as pd files = list(Path("./data").glob("*.xlsx")) df_list = [] for file in files: df = pd.read_excel(file, sheet_name="数据", dtype={"订单号": str}) df["文件名"] = file.stem # 记录来源,方便排查 df_list.append(df) result = pd.concat(df_list, ignore_index=True)有几个细节值得说:
glob("*.xlsx")不会匹配.xls,如果有两种格式,需要分别处理。- 每个文件读取后要加一列文件名作为来源,否则后面发现问题时根本不知道这条数据来自哪个文件。
concat时不要用默认索引,设置ignore_index=True,保证最终索引连续。- 如果文件数量很大,建议先读一个文件了解结构,保持一致后再批量处理。如果各文件的sheet名或列名不同,批量处理会出大问题。
6.2 写Excel时可以控制起始单元格
to_excel里有两个不算太显眼但很实用的参数:startrow和startcol。它们决定从哪个位置开始写入。
with pd.ExcelWriter("output.xlsx") as writer: report.to_excel(writer, sheet_name="汇总", index=False, startrow=0, startcol=0) df.to_excel(writer, sheet_name="汇总", index=False, startrow=len(report) + 3, startcol=0)这种方法可以在同一个sheet里上下堆叠两个表,中间留空行。虽然不常见,但做那种“一页纸报表”时挺有用,比如上面放汇总,下面放明细。
要注意的是,startrow是从0开始计的,不是从1。如果你希望Excel的第1行不写数据,从第2行开始,startrow应该等于1。我在这上面犯过错,导出的表头跑到第二行去了。
6.3 写入公式而非硬编码数值
Pandas本身不鼓励你往DataFrame里塞公式,因为公式是Excel层的东西。但有时候报表需要在Excel里保留动态计算,比如“合计”这一格。
你可以在用to_excel写出后,再用openpyxl往特定单元格写公式:
from openpyxl import load_workbook with pd.ExcelWriter("output.xlsx", engine="openpyxl", mode="a", if_sheet_exists="overlay") as writer: df.to_excel(writer, sheet_name="汇总", index=False, startcol=0) wb = load_workbook("output.xlsx") ws = wb["汇总"] ws["E10"] = "=SUM(E2:E9)" wb.save("output.xlsx")用xlsxwriter引擎也可以直接写公式:
with pd.ExcelWriter("output.xlsx", engine="xlsxwriter") as writer: df.to_excel(writer, sheet_name="汇总", index=False) workbook = writer.book worksheet = writer.sheets["汇总"] worksheet.write_formula("E10", "=SUM(E2:E9)")这里要注意,公式在Python读取时不会自动计算,除非Excel软件打开并重新计算过。所以如果你用代码回读这个文件,那个公式单元格的值大概率是0或者None。验证公式结果的时候别直接用Pandas读,要先用Excel打开保存一次再读,或者接受这个限制。
我个人在实际操作中的体会是,Excel的读写从来不是“API背熟”就能做好的事,真正的功夫在于对文件本身的理解。拿到一个Excel,第一件事不是写代码,而是先搞清楚有几个sheet、表头在哪几行、列里混了什么脏值、日期是真是假。把这几个问题确认完,代码往往就是几行read_excel和to_excel的事。
最后再分享一个小习惯:每次导出Excel后,我都会立刻用pd.read_excel读回来,至少看一眼shape和dtypes。这个习惯帮我在很多项目里提前发现过“索引列混进去了”“日期变成序列号”“金额列多出一堆NaN”等低级问题。你在这一步栽过一次跟头,就会明白这个回读动作有多值钱。