Python操作.xls文件:xlrd+xlwt+xlutils实现读写与格式保留
1. 为什么读同一个Excel文件还得靠三个库打配合先说一个特别常见的尴尬场面你接了个活儿要把某个老系统导出的Excel表里的数据批量更新一遍比如根据A列的手机号把B列的套餐等级从普通改成高级改完还得保留原来的格式最后再存回那个文件。你脑子里第一个想到的肯定是直接用Python打开、修改、保存一步到位。等你真动手了才发现事情没那么简单。在Python处理Excel的老牌生态里对.xls格式也就是Excel 97-2003版本有一套非常明确的分工体系xlrd负责读取数据xlwt负责从零开始新建并写入文件xlutils负责在二者之间做桥接。它们仨各管一段谁也没打算把打开一个已有文件然后原地修改这件事包圆。你如果只装了xlwt想直接改一个已经存在的文件不好意思它没有这个能力——它只会从白纸开始画。你如果只装了xlrd那更惨它连写都不会写。所以问题的本质在于Python标准库和第三方库里没有一个单独的工具能对.xls文件做打开-读写-保存的完整闭环。解决办法就是用组合拳先用xlrd把原文件完整读进来再用xlutils.copy把读进来的工作簿复制成一个新的可写对象最后用xlwt在新副本上做修改并另存覆盖。这套路数看起来绕了一圈但它是处理老式Excel文件最稳定、最经典、坑最少的方案。这篇东西适合谁看两类人。第一类是天天跟遗留系统导出的.xls文件打交道的业务开发第二类是还在读书、想搞明白读写分离设计到底解决什么问题的学生。我尽量把原理、代码、坑全讲透你照着抄就能用。2. 三个库的分工边界谁只读、谁只写、谁当中间人在动手写代码之前必须先搞清楚这三个库各自的脾气。很多人写不好这类脚本不是因为代码语法有问题而是没理解每个库的能力边界。2.1 xlrd只能读而且读得很仔细xlrd的全称是Excel Reader它的定位非常纯粹读取.xls格式的数据。它能拿到工作表列表、单元格的值、行数列数、日期数值也能处理合并单元格信息但它的写入能力是零。也就是说你可以在它上面做任意复杂的查询逻辑但你别指望它能帮你保存任何修改。有个值得注意的点xlrd对.xlsx格式的支持在新版本里已经移除了。很多新手装上xlrd之后试图读.xlsx然后一脸懵——其实现在官网明确说了只支持到.xls。如果你想处理新格式要么用openpyxl要么用pandas别在xlrd上耗着。2.2 xlwt只能写而且是新建式写入xlwt是Excel Writer它擅长的是生成一个全新的工作簿从零开始定义工作表、写入单元格数据、设置样式、调整列宽最后保存成.xls文件。它的逻辑决定了它有一个死穴它无法打开一个已有的工作簿。在xlwt的世界观里每个工作簿对象都像一个全新的空白本子你没有接口去加载一个磁盘上的文件进来。所以只要你处理的文件不是新建的光靠xlwt是救不了场的。2.3 xlutils负责复制让读过的变成能写的xlutils这个名字听起来很抽象但核心功能极其实用它的copy模块能把xlrd读出来的工作簿对象整体移植成一个xlwt风格的可写工作簿对象。这个过程中原有单元格的值、样式信息都会被尽量保留下来。这是整个读写方案里最关键的机制。它解决的是我怎么在保留原有内容的前提下做增量修改这个核心问题。你可以把这一步理解为把一本已经印好的书完整复印一遍然后在复印件上直接批注、划线最后把批注后的复印件当成新书保存回去。原本的书没动复印件上的一切改动都是允许的。有了这个机制我们的操作流程就非常清晰了用xlrd打开原始文件读取完整内容。用xlutils.copy.copy()把读到的对象转成可写对象。用xlwt风格的API修改、新增、删除单元格。用wb.save()保存回同一个路径覆盖原文件或另存为新文件。3. 一套完整的读写实操从读取到修改再到覆盖保存这一节我直接给你一个能跑的完整案例然后逐段拆解。我尽量把代码写实一点不要那种演示用的玩具代码。3.1 准备环境和基础文件先安装依赖pip install xlrd1.2.0 xlwt1.3.0 xlutils2.0.0注意我把xlrd版本钉在了1.2.0。原因后面专门讲这是个巨坑。你先把版本装上后面就知道为什么了。然后准备一个测试文件新建一个Excel 97-2003格式的工作簿里面放一个名叫会员信息的工作表输入三列数据手机号、套餐等级、月消费金额。随便填几行比如手机号套餐等级月消费金额13800001111普通5813800002222黄金8813800003333钻石158保存为members.xls。注意文件后缀必须是.xls别想着用.xlsx测试这三个库都不认。3.2 核心代码读进内存复制改造覆盖保存import xlrd from xlutils.copy import copy as xl_copy # 第一步用xlrd读入原工作簿 read_book xlrd.open_workbook(members.xls, formatting_infoTrue) # 第二步用xlutils.copy复制成可写对象 write_book xl_copy(read_book) # 第三步获取工作表 read_sheet read_book.sheet_by_name(会员信息) write_sheet write_book.get_sheet(会员信息) # 第四步遍历行做修改 for row_idx in range(1, read_sheet.nrows): # 跳过表头 # 读取这一行的套餐等级 level read_sheet.cell_value(row_idx, 1) if level 普通: # 在写对象上修改套餐等级 write_sheet.write(row_idx, 1, 高级) # 顺手把月消费金额也改了第2列是消费金额 write_sheet.write(row_idx, 2, 99) # 第五步另存为新文件避免误覆盖原文件 write_book.save(members_updated.xls)这段代码的逻辑非常直观先读旧表然后复制出可写版本遍历每一行判断套餐等级需要改的就用write()覆盖对应单元格最后保存。这里有个容易绕晕的地方修改时要往write_sheet上写但读取判断时要读read_sheet的内容。因为write_sheet是从副本来的你写一行它就变一行如果你用write_sheet去读取后面还没改的行虽然理论上也能读到原始值但一旦你在前面改了同一行的值再读就会出现读取到的是修改后的值这种干扰。最稳妥的做法就是保持读、写分离判断逻辑全部基于read_sheet写入动作全部对着write_sheet。3.3 逐行覆盖保存不只是另存为上面这个例子把结果保存成了新文件这在很多场景下足够用了。但有些业务场景要求必须覆盖原文件——比如下游系统只认固定文件名。这时候你只需要把保存路径改成原路径write_book.save(members.xls)覆盖保存本身没什么神秘的关键是要保证写入过程中不会出意外。我的建议是不要直接覆盖原文件先保存成临时文件再替换这样万一脚本半路报错原文件还在不至于弄丢数据。我实际项目里都是这样处理的import os write_book.save(members_tmp.xls) os.replace(members_tmp.xls, members.xls)os.replace在Windows和Linux上都能用属于原子替换比先删后改稳妥得多。这个习惯养成了能帮你少求很多人。3.4 更实用的批量修改场景根据条件跨列联动上面的例子太单薄实际业务里往往是多条件联动。我再写一个综合一点的需求如果某用户是黄金套餐且月消费低于100就把他提升为铂金并把月消费调整为128。import xlrd from xlutils.copy import copy as xl_copy read_book xlrd.open_workbook(members.xls, formatting_infoTrue) write_book xl_copy(read_book) read_sheet read_book.sheet_by_name(会员信息) write_sheet write_book.get_sheet(会员信息) updated_count 0 for row_idx in range(1, read_sheet.nrows): phone read_sheet.cell_value(row_idx, 0) level read_sheet.cell_value(row_idx, 1) amount read_sheet.cell_value(row_idx, 2) if level 黄金 and amount 100: write_sheet.write(row_idx, 1, 铂金) write_sheet.write(row_idx, 2, 128) updated_count 1 print(f已更新: {phone} {level} - 铂金) write_book.save(members_updated.xls) print(f共更新 {updated_count} 行)这种代码没什么高大上的语法但非常实用。你做的时候唯一要注意的就是row_idx别算错。Excel的行号从1开始而xlrd的row_idx从0开始所以如果你把表头当作第1行那么第一条数据是row_idx1千万别上来就range(0, nrows)不然表头可能被当数据修了。4. 那些你不踩一次就不知道的坑版本、格式与样式这组库用了这么多年网上能搜到的问题基本都集中在几个固定区域。我把我自己踩过和帮别人排查过的坑统一列一下。4.1 xlrd版本选择1.2.0之前和之后是两个世界这是目前最坑的一点。xlrd从2.0版本开始移除了对.xlsx文件的支持但如果你只处理.xls问题不大。真正的地雷在于旧代码里常见的xlrd.open_workbook()用法在2.0版本里依然能用但返回的数据结构有细微差异尤其是日期单元格的类型判断。网上大量教程是基于1.2.0写的你要是装了个2.x版本照着跑可能直接报错或者出现奇怪的类型判断问题。我的建议很直接如果你不处理.xlsx就老实装xlrd1.2.0然后别升级。这不是说你不能用新版而是没必要给自己添麻烦。生产环境里稳定不折腾永远排在用最新版前面。4.2 formatting_infoTrue保留格式的关键开关在xlrd.open_workbook()里有一个参数叫formatting_info它的默认值是False。如果你不把它设为True那么xlutils.copy复制出来的工作簿在样式上会丢失大量信息新写入的单元格不会有和原表格一致的格式有时候甚至会把原本的列宽、行高、背景色全部打乱。为什么默认是False因为完整读取格式信息对性能有影响大文件尤其明显。但对于做读取-修改-保存这种操作来说我建议一律显式传入True。不然你做出来一个数据是对的、格式却丑得没法看的文件交付出去很尴尬。4.3 覆盖保存后样式丢失或错乱即使你按我上面说的开了formatting_infoTrue依然可能出现局部样式错乱尤其是当你修改的单元格原本带特殊格式比如合并单元格区域、条件格式、数据验证的时候。我遇到过一个具体案例某表格的前三列是合并单元格脚本只改了第四列的数据结果保存后合并区域塌了。排查了很久发现xlutils.copy对合并单元格merged cells的支持并不完美特别是跨行合并的复杂场景它复制出来的对象在合并信息上会丢。本质上这是xlutils这个库本身的设计局限没有太好的根治办法。我的经验是能不用这三个库处理带大量复杂格式的表格就尽量别用。如果只是数据搬运、简单格式的表格放心用。如果表格里有大量的合并单元格、图片、图表、下拉列表这些东西的保留能力都有限。你需要冷静判断自己的场景是否在它的能力范围之内。4.4 日期单元格的读取陷阱.xls文件里的日期存储方式是浮点数xlrd负责把这个浮点数解释成人能读的日期格式。它本身有个xlrd.xldate_as_datetime接口可以用但你要注意区分不是每个看起来像日期的单元格都会返回日期类型。举个例子有些表格里日期是通过字符串写入的比如2024-01-15。这种情况下cell_type是文本类型XL_CELL_TEXT你用日期处理的逻辑去做就会出错。最稳的做法是同时判断单元格类型再决定怎么处理from xlrd import xldate_as_datetime cell_type read_sheet.cell_type(row_idx, col_idx) cell_value read_sheet.cell_value(row_idx, col_idx) if cell_type xlrd.XL_CELL_DATE: date_val xldate_as_datetime(cell_value, read_book.datemode) print(date_val.strftime(%Y-%m-%d)) elif cell_type xlrd.XL_CELL_TEXT: print(cell_value.strip())代码不复杂但很多人因为没做类型判断在日期数据上翻车。这个细节值得刻在脑子里。4.5 空值和特殊字符的处理表格里的空单元格在用xlrd读取时返回的是空字符串在文本模式下但有些版本会返回None。你要是在遍历的时候直接做字符串操作比如level.strip()就可能碰到AttributeError。我通常建议在读取每个单元格后做一层防御性处理def safe_str(value): if value is None: return return str(value).strip()遇到空值当成空字符串处理遇到数字就转字符串这样后续逻辑就统一了。虽然代码丑一点但排错成本大大降低。5. 两个真实的翻车现场从现象到根因的排查链路光讲理论不够我分享两个真实项目里遇到的翻车案例把完整的排查思路走一遍你以后再遇到类似问题会更有方向感。5.1 案例一保存后文件打不开提示格式错误现象脚本跑完没报错输出的.xls文件双击却打不开Excel提示文件格式和扩展名不匹配。我的排查步骤第一反应是怀疑保存路径的问题。检查发现文件名后缀无误路径正常数据量也不大。接着怀疑是写入内容本身包含非法字符比如某些用户在备注字段里填了特殊控制字符。逐个检查写入的值没发现明显异常。然后我想到可能是xlutils.copy在对该文件的某些结构复制时产生了损坏。于是我做了一个对照实验把同一个文件不做任何修改直接copy后保存。结果发现即使什么都不改保存出来的文件就打不开了。这就把问题范围缩小到了复制阶段就破坏了文件结构。进一步排查发现这个源文件虽然后缀是.xls但它的实际内部格式可能是另一种结构有些老系统会生成伪装的HTML或XML表格。xlrd能勉强读取内容但xlutils在复制时无法正确重建内部结构最后产出了一个半吊子文件。解决方案先手动把源文件用Excel另存为标准.xls再跑脚本或者干脆不用这套组合改用pandas做读取和清洗再用xlwt重建一份新表。这个案例给我们的教训是遇到读取正常、保存损坏的问题先怀疑源文件格式的纯净性。.xls后缀不代表它一定是个标准二进制Excel文件很多业务系统导出的东西是挂羊头卖狗肉。5.2 案例二读取的数据总数没问题但行顺序全乱了现象一张几万行的.xls表用xlrd读出来打印nrows是准的但按行顺序打印内容时发现顺序和源文件里不一致像是被排序过。我一开始以为是自己遍历逻辑出错了检查半天没毛病。后来偶然发现源文件本身就处在筛选状态——用户在Excel里开了自动筛选还手动排过序。这种情况下xlrd读取的是底层真实数据顺序而不是用户在界面上看到的显示顺序。显示顺序和存储顺序不一样这是Excel的正常行为不是bug。这类问题没有所谓的修复方案因为你的脚本面对的本来就是存储数据。如果你希望按用户看到的顺序处理唯一的办法是先在Excel里取消筛选并把排序状态清掉再保存一次。如果这个文件是程序自动生成的那就检查生成端是否在保存之前应用了排序视图。这个小案例看起来不起眼但在实际业务里特别容易让开发者和对表的人对不上账。我建议你在接手任何交付的Excel文件时先问一句这个表有没有开筛选或者分页能省掉大量无谓的排查时间。5.3 案例三大批量写入速度慢到怀疑人生现象对一张有6万行数据的.xls表做批量修改每行要写4个单元格结果脚本跑了十分钟还没结束。原因分析xlwt在写单元格时本身是内存操作理论上是很快的。问题出在很多人会在每写一行之后就调用一次flush()或save()甚至有人把save()放在循环里每行保存一遍。这个操作会反复序列化整个工作簿数据一多自然慢到爆炸。正确做法是所有写入动作完成后再统一save()一次循环里绝对不要出现保存调用。另外如果确实有超大数据量需求你可能要考虑是不是该用.xlsx格式加openpyxl而不是死磕这三个老库。6. 老库之外的选择什么时候该换方案什么时候继续用很多人学了这套xlrd/xlwt/xlutils组合拳之后会有一种我什么Excel都能处理的错觉。实际上这套组合的适用范围远没有你想的那么广我做了一个对比供你参考需求场景推荐方案理由处理.xls老格式简单格式、常规数据修改xlrd xlutils xlwt最稳老库对.xls兼容性最好处理.xlsx新格式需要保留图表图片等openpyxl原生支持.xlsx功能全面大量数据分析、统计、聚合类操作pandas openpyxl数据处理能力强写回方便服务端生成Excel报表样式要求高xlsxwriter写入性能强样式控制精细需要自动化操作桌面Excel软件打开、公式重算等win32com仅Windows直接驱动Excel进程能力最强但依赖环境我的判断标准很简单如果你的输入文件都是标准.xls、表格结构规整、格式不复杂、数据量在几万行以内那么这套老组合是最好用的没必要换。但只要你发现自己在和复杂格式新格式超大数据量任何一个词沾边就趁早换技术栈。最典型的情况是新项目如果还在用.xls我是建议尽早推动系统升级成.xlsx输出。数据格式升级是一次性成本但省下来的处理麻烦是长期的。当然这属于业务推动层面的事技术上你依然得先兜住当前的.xls需求。我实际项目里还试过一种混合方案用xlrd把.xls数据读出来转成中间结构比如pandas.DataFrame或者Python原生列表清洗加工完之后再用xlwt或者openpyxl重建一张新表输出。这样的好处是绕开了xlutils在保留原格式上的短板坏处是输出的表格样式得从头画。如果业务方不要求原始格式这个方案性能往往更好、出错率也更低。7. 一段可以直接抄进项目的通用封装最后我放一个带防御性逻辑的通用封装函数你日常做.xls读改写可以把它作为基础工具直接用。它把版本选择、临时文件替换、类型判断都包进去了。import os import xlrd from xlutils.copy import copy as xl_copy def update_xls(file_path, sheet_name, modify_func): 读取、修改、保存同一个.xls文件。 参数: file_path: 原文件路径 sheet_name: 要修改的工作表名 modify_func: 修改函数接收(read_sheet, write_sheet, wb, sheet_idx) 其中 wb 是 xlrd 的工作簿对象 # 1. 读取阶段保留格式信息 read_book xlrd.open_workbook(file_path, formatting_infoTrue) write_book xl_copy(read_book) # 2. 常规校验 if sheet_name not in read_book.sheet_names(): raise ValueError(f工作表 {sheet_name} 不存在可选为 {read_book.sheet_names()}) sheet_idx read_book.sheet_names().index(sheet_name) read_sheet read_book.sheet_by_index(sheet_idx) write_sheet write_book.get_sheet(sheet_idx) # 3. 执行业务修改逻辑 modify_func(read_sheet, write_sheet, read_book, sheet_idx) # 4. 先存临时文件再原子替换 tmp_path file_path .tmp write_book.save(tmp_path) os.replace(tmp_path, file_path)调用方式可以这样写def my_modify(read_sheet, write_sheet, wb, sheet_idx): for row in range(1, read_sheet.nrows): val read_sheet.cell_value(row, 0) if val 目标: write_sheet.write(row, 0, 已替换) update_xls(data.xls, Sheet1, my_modify)这个封装的核心思想是把读写保存的通用流程和具体的业务修改逻辑解耦。你以后不管改什么表业务逻辑只需要写在modify_func里就好了IO层的细节不用反复写。我在做这套封装时最大的体会是这三个库虽然老但只要理解它们的定位用起来其实很顺手。你不需要会多少高深的技巧只要把握好读原始数据、写复制对象、最后统一保存这条主线大部分需求都能平趟。尤其是遇到遗留系统的.xls文件时这套组合依然是目前最有效、最不容易出状况的解决方案。