Python批量Excel转CSV:格式原理、编码陷阱与性能优化
简介一款面向办公与数据处理场景的小型工具包用于将指定目录下的 xls/xlsx 文件批量转换为 csv免去逐一手动另存的重复操作适合财务、运营、数据分析等需要频繁整理表格的用户也可供 C 开发者作为参考。整套 RAR 压缩包仅 94KB共 33 个文件bin 目录下提供可直接运行的 ExcelToCsv.exe另有 7 个头文件、5 个 cpp 源码文件、整套 VC 工程配置以及 2 个 xls、1 个 xlsx 和对应 csv 示例覆盖从源码到成品的完整链路。已有 2682 人学习下载。普通用户解压后即可使用无需额外安装环境开发者则可从工程中看到 MFC 对话框程序如何调用 Excel 接口完成打开文件、遍历工作表、写入 csv 等操作并理解原版因 Font 类与 Windows SDK 重定义而编译失败时的修改思路是一份麻雀虽小五脏俱全的批量转换参考工具。1. 批量Excel转csv工具与其手动另存为不如一次写透转换逻辑手上压着几百个 xlsx 报表要转成 csv 给下游系统用逐个打开 Excel 点“另存为 CSV”再处理弹窗和编码问题一上午就这么没了。批量 Excel 转 csv 工具解决的正是这个场景把散落在多级目录里的 xlsx/xls 文件按统一规则输出为 csv同时管住编码、分隔符、sheet 选择和文件名映射。适合报表岗、数据处理、ETL 维护者以及任何每周要跟 Excel 文件打交道的从业者。它不复杂但里面的坑比想象中多尤其日期序列号、隐藏 sheet 和中文编码这三关下面逐个拆开讲。2. 选型与原理xlsx 不是“Excel”csv 也不是“纯文本”这么简单2.1 文件格式差异zip 包、OLE 流与纯文本很多人在写批量转换之前根本没弄清楚 xlsx、xls、csv 三者底层差异。xlsx 是 Office Open XML 格式本质是一个 zip 压缩包里面装着 sheet 的 XML 定义、样式表、共享字符串表。xls 是 OLE2 复合文档格式二进制流直接存储解析起来比 xlsx 更费劲。csv 则是纯文本一行一条记录字段之间用分隔符隔开没有样式、没有公式、没有多 sheet。这个差异直接决定了转换工具的选择。如果你用 Python处理 xlsx 最常见的库是 openpyxl 和 pandas底层读 xlsx 时由 openpyxl 负责读 xls 时由 xlrd 负责。但请注意openpyxl 读取 xlsx 是“完整解析”的会把所有共享字符串、行列索引都加载进内存而 csv 输出是流式写文本。这里就埋下了一个性能隐患——一个 50MB 的 xlsx用 pandas 默认方式读进来内存占用可能到 300MB。后面会有专门的优化方案。再一个容易忽略的点csv 没有固定的编码标准。Windows 上 Excel 默认用 ANSI比如 GBK保存 csv而 Python 默认读 utf-8。如果不统一编码转出来的文件在别人的 Excel 里打开就是乱码。很多批量转换脚本跑完“成功”了实际输出的全是乱码这就是没处理编码问题。2.2 工具选型为什么我推荐 Python pandas而不是直接改后缀有人会说把 xlsx 改名成 csv 不就行了这是最常见的误解。直接改后缀文件内容依然是 zip 包的 XMLcsv 解析器拿到的是二进制乱码根本没法用。另存为对话框每次只能导出一个文件而且 Excel 的“CSV UTF-8”和“CSV逗号分隔”两种格式在部分版本里还会把 UTF-8 文件加上 BOM不加 BOM 的话 Excel 打开又会乱码。所以真正的批量转换必须走程序。三种常见路径对比方案适用场景缺点Excel VBA 宏只在 Windows 本机处理且 Excel 环境固定跨平台差代码维护麻烦大批量容易卡死Python pandas / openpyxl最灵活可控编码、sheet、格式需要 Python 环境首次安装依赖有门槛命令行工具如 xls2csv快速处理少量文件对 xlsx 支持弱编码和 sheet 选择能力有限我一般用 Python pandas openpyxl 组合。pandas 的 read_excel 可以自动嗅探文件类型输出时用 to_csv再配合 pathlib 做路径迭代整个批量逻辑能控制在 50 行以内。而且 pandas 的 DataFrame 中间层让你可以在转换前做列筛选、类型强制比如把日期列统一成字符串避免 Excel 序列号串场。2.3 批量转换的通用框架先扫目录再逐文件处理最后校验不管用什么语言批量转换的核心流程只有三步遍历文件、读取并转换、写 CSV 并校验。这里有个关键设计——把“转换单个文件”和“遍历目录”分开。单文件转换函数要保持独立输入是一个文件路径输出是一个 csv 路径这样既方便单测也为后面增量转换、定时任务留了接口。遍历时用 pathlib 的 glob 支持递归匹配比如Path(root).rglob(*.xlsx)。注意 rglob 不会匹配以点开头的隐藏目录这点对服务器上的临时文件很有用。还要注意文件名可能不区分大小写Windows 上.XLSX和.xlsx都能被 glob 匹配到所以正则里要写.xls[x]?\b避免把.xlsb这类二进制工作簿也吞进来。实际项目里我遇到过用户把.xlsm带宏也混进来openpyxl 能读但可能会触发宏相关警告所以要么过滤掉.xlsm要么用 try-except 统一兜底。3. 核心脚本实现从单个文件到多目录批量手写一份可落地的转换器3.1 基础转换脚本先保证单个文件输出正确写批量之前先写一个纯函数处理单个 Excel 文件。这里用 pandas 读取 Excel 所有 sheet 并逐个输出同名 csv。要注意read_excel必须指定sheet_nameNone才能拿到所有 sheet返回的是一个{sheet_name: DataFrame}的字典。然后遍历字典把每个 DataFrame 写入单独的 csv 文件。# convert_single.py import pandas as pd from pathlib import Path def excel_to_csv(excel_path: Path, output_dir: Path, sheet_namesNone): 将单个 Excel 文件中的所有 sheet 转换为 csv 文件 :param excel_path: 输入的 xlsx/xls 文件路径 :param output_dir: 输出目录会自动创建 :param sheet_names: 需要转换的 sheet 名列表None 表示全部 output_dir.mkdir(parentsTrue, exist_okTrue) # sheet_nameNone 表示读取全部 sheet返回字典 all_sheets pd.read_excel(excel_path, sheet_nameNone, dtypeobject) for sheet_name, df in all_sheets.items(): if sheet_names and sheet_name not in sheet_names: continue # 用 Excel 文件名 sheet 名拼接 csv 文件名避免多 sheet 覆盖 csv_name f{excel_path.stem}_{sheet_name}.csv csv_path output_dir / csv_name # indexFalse 去掉行号utf-8-sig 编码Excel 打开不乱码 df.to_csv(csv_path, indexFalse, encodingutf-8-sig) print(f[OK] {excel_path.name} - {csv_name}, 共 {len(df)} 行)逻辑说明读取全部 sheet 时pandas 会为每个 sheet 单独解析内存中同时存在多个 DataFrame。文件很多时建议逐个 sheet 处理避免内存叠加。参数dtypeobject强制所有列按文本读入保留 Excel 里的原始显示值防止 pandas 自动把“001”变成数字 1。utf-8-sig会在文件头写入 BOMExcel 打开 UTF-8 csv 时不会乱码代价是写入字节多 3 个对 csv 来说完全可接受。参数设计上sheet_names是给“指定 sheet”用的。如果你只想要所有文件里的“Sheet1”把这个列表传[Sheet1]即可。注意 sheet 名区分大小写Excel 默认 Sheet1 首字母大写。另外len(df)报告的行数是 pandas 读取到的行不包括 Excel 的空行但包含所有非空行。如果 sheet 里有公式pandas 拿到的是公式的缓存结果即最近一次 Excel 打开时计算出的值而不是公式本身。这是个优点因为你通常想要值而不是公式。3.2 批量目录遍历用 pathlib 和 glob 处理多级目录单文件函数就绪后批量遍历就是套一个循环。这里推荐用Path.rglob递归获取所有匹配文件。处理时的两个关键点一是输出目录要保留原文件的相对路径结构否则所有 csv 堆在一层同名文件会互相覆盖二是要跳过输出目录本身防止循环把已生成的 csv 再当成输入。# batch_convert.py import pandas as pd from pathlib import Path def batch_convert(root_dir: str, output_root: str, suffix.xlsx): 批量转换目录下所有 Excel 文件为 csv :param root_dir: 源目录会递归搜索 :param output_root: 输出根目录保持相对路径 :param suffix: 文件后缀默认 .xlsx可传 .xls source Path(root_dir) target Path(output_root) # rglob 返回生成器不会一次性加载全部文件路径大目录下内存友好 file_iter source.rglob(f*{suffix}) # 过滤掉隐藏目录和临时文件文件名以 ~$ 开头的是 Excel 缓存文件 files [p for p in file_iter if not p.is_relative_to(Path(output_root)) and not p.name.startswith(~$)] print(f找到 {len(files)} 个 {suffix} 文件) for excel_file in files: # 相对路径映射到输出目录保持目录结构 rel_path excel_file.relative_to(source) out_dir target / rel_path.parent try: excel_to_csv(excel_file, out_dir) except Exception as e: print(f[FAIL] {excel_file}: {e}) continue逻辑说明rglob是生成器在文件数量上万时不会撑爆内存但随后我用列表推导式给它转成了列表这一步其实会占内存。如果目录超过 5 万文件建议直接遍历生成器用if逐个判断不要铸造列表。命令里is_relative_to是 Python 3.9 才有的方法老环境可以用str(p.resolve()).startswith(str(target.resolve()))代替。另一个细节excel_to_csv函数内部对每个 sheet 都写一个 csv如果某个 excel 有 10 个 sheet就会产生 10 个 csv。你可能只想要第一个 sheet或者只想要名字包含“summary”的 sheet。这时候不要改excel_to_csv的参数而是在调用前用pd.ExcelFile快速读取 sheet 名列表然后筛选避免把整个文件读进来再丢# 快速获取 sheet 名 with pd.ExcelFile(excel_file) as xls: all_sheets xls.sheet_names # 取第一个 sheet 或按需过滤 selected all_sheets[:1] if not all_sheets else None3.3 逗号、分号还是制表符CSV 方言参数解析CSV 并不是一门统一的标准不同系统对分隔符和引号的处理不同。pandas 的to_csv默认用逗号作为分隔符但有些数据库导入工具或老系统只认分号或者是 Tab 缩进。输出前要问清楚下游的解析规则否则就要自己处理包含逗号的字段。# 自定义分隔符和引号规则 df.to_csv(csv_path, indexFalse, sep;, # 分号分隔适合欧洲区域设置 quotechar, # 引号符默认就是双引号 doublequoteTrue, # 字段内双引号转义为两个双引号 lineterminator\n, # 固定换行符为 \n防 Windows 写成 \r\n encodingutf-8-sig)参数说明sep改动后csv 文件的命名也要带上分隔符标识比如_sep_semicolon.csv否则后续维护的人分不清。lineterminator很重要Excel 保存的 csv 常使用\r\n而 Linux 下的多数解析器默认按\n切行混合换行符会让最后一列带出\r肉眼看不出来但下游 join 表的时候就会报错。我一般强制统一为\n。doublequoteTrue表示字段内容里有时用两个双引号转义这是标准行为不要改成escapechar方式除非你确认下游支持。4. 数据清洗与性能优化编码、日期序列号、大文件内存控制4.1 中文乱码的根源Excel 的笨和 pandas 的不肯将就中文乱码几乎成为转换工具的第一大客服问题。根源有两个一是源文件本身是 GBK 编码的老 .xls二是输出 csv 没有加 BOM。老 .xls 文件用 pandas 读取时如果指定了引擎xlrd它对中文通常能猜对。但如果你用 openpyxl 去读一个真正的 .xls就会直接报错。所以建议对 .xls 和 .xlsx 分别指定引擎。输出这边utf-8与utf-8-sig的区别就是前三个字节EF BB BF。Windows 下的 Excel 打开没有 BOM 的 utf-8 csv 时会按 ANSI 解码中文必乱。反之加了 BOM 的 utf-8 csvExcel 能正确识别。如果下游是 Python 的 csv 模块BOM 会变成字段中第一个字符的\ufeff需要utf-8-sig读取再清洗。这里没有银弹只能按下游规则来。一个隐蔽的坑当 Excel 源文件里的某个 sheet 名是中文而输出 csv 文件名包含中文时Windows 下文件名默认用 Unicode 存储一般没问题。但如果在 Linux 服务器上运行且文件系统是 ext4中文文件名没问题如果挂载点是 CIFSSamba文件名编码可能变成 UTF-8 或 GBK导致找不到文件。处理办法输出文件名统一用拼音或数字标识或者在使用前用os.fsencode检查。4.2 日期字段为什么会变成一串 44309Excel 日期序列号解析Excel 内部把日期存成序列号1900-01-01 是 144309 代表 2021-05-01默认 1900 日期系统。pandas 读取日期列时默认会尝试解析但如果没有显式指定parse_dates它可能把整列识别成 float 或 int输出时就写成 44309。这是项目里最常见的“数据变了”的投诉。解决方案有两个一是读取时指定日期列pd.read_excel(..., parse_dates[“日期列名”])二是把日期列转成字符串格式比如统一为YYYY-MM-DD。实践中我更推荐后者因为 csv 是纯文本字符串形式能保留前端展示格式而且下游按字符串 join 也方便。# 日期格式统一处理 df pd.read_excel(excel_path, sheet_nameSheet1, dtype{日期: str}) # 把 Excel 序列号转换为指定格式先转 datetime再格式化 df[日期] pd.to_datetime(df[日期], errorscoerce).dt.strftime(%Y-%m-%d)参数说明dtype{日期: str}让 pandas 先把这列按文本读入但这列实际内容是 Excel 的序列号数字比如 44309文本读入就成了44309。下一步pd.to_datetime默认会把这列识别为 Excel 序列号吗不会to_datetime不认 Excel 序列号它会把44309解析成 1971 年的日期因为 44309 会被当作纳秒或天需要验证。正确做法是直接读成数值再调用pd.to_datetime(44309, unitD, origin1899-12-30)。这里有一个公认的偏移量Excel 的 1900 日期系统把 1900-02-29 这个不存在的一天也算进去了所以 Pandas 的 origin 要设置成 1899-12-30 才能对齐。更省心的方案是让 pandas 自动识别日期列读取时不指定 dtypeXLSX 文件里日期列通常带单元格格式标记pandas 能识别出来。但保险起见我会加一个模式识别如果某列数据全部落在合理日期范围内且数据类型是 float就提示用户手动指定。代码示例如下# 自动尝试识别日期列启发式 from pandas.api.types import is_float_dtype, is_integer_dtype for col in df.columns: if df[col].dropna().empty: continue if is_float_dtype(df[col]) or is_integer_dtype(df[col]): sample df[col].dropna().iloc[0] # 序列号范围约在 1 到 70000 之间对应 1900-12-31 到 2100 if 1 abs(sample) 70000: print(f[WARN] 列 {col} 可能是日期序列号请确认是否转换)这种启发式在混有大量 ID 数字诸如订单号 44000时会有误报所以只打印警告不做自动转换让用户自己决定。4.3 万行级文件提速逐块读取与只取需要的列pandas 的read_excel在读取 10 万行、20 列的 xlsx 时速度尚可但如果一次批量处理上百个每个文件都全量读入性能会直线下降。优化手段有三层第一只读取需要的 sheet 和列。pd.read_excel(excel_path, sheet_nameSheet1, usecolsA:C)使用usecols参数可以只加载前几列。注意usecols支持 Excel 列字母也支持列名列表。如果列名里有重复使用列名字符串列表会报错字母方式更稳定。第二尽量用openpyxl的read_onlyTrue模式手动流式读取但那样会失去 pandas 的表格整理能力。折中方案是对于超大文件超过 5 万行先用 openpyxl 只读模式做一次“数据汇总”把需要的行筛选出来后再转成小 DataFrame。这一步比较复杂如果项目不追求极限性能还是让 pandas 直接读然后用chunksize处理 CSV 输出——注意read_excel没有chunksize这是read_csv才有的参数。Excel 的 XML 结构决定了必须整体解析没有流式 API。因此对大 Excel最好的办法是控制并发数量不要同时开多个 pandas 读取。第三处理完单个文件后立即del df并调用gc.collect()。在长循环里pandas 的DataFrame释放内存并不及时Python 的垃圾回收是引用计数为主循环内的局部变量在下次迭代时会被替换但内存碎片可能残存。执行del后再gc.collect()能稳定内存占用。实测 200 个文件的小批量转换内存可以从 800MB 降到 400MB。import gc for excel_file in files: df pd.read_excel(excel_file, sheet_nameSheet1) # ... 处理 ... df.to_csv(out_file, indexFalse) del df gc.collect()5. 避坑与排错六个真实踩坑记录照着检查能省半小时5.1 现象FileNotFoundError: [Errno 2] No such file or directory但文件明明就在那原因文件名里包含特殊字符比如中文全角空格、#号或者路径里存在 Windows 的保留符。最常见的是文件名以.xlsx结尾但实际是隐藏字符比如 xxx.xlsx 后面有个不可见字符Python 的 glob 匹配不上。还有权限问题文件在共享盘上虽然能看到但读取时没有打开权限。解决先打印出文件的绝对路径和os.stat()信息确认路径中每个字符的repr。用Path.resolve()规范化路径再用os.access(path, os.R_OK)检查可读性。脚本里在读取前先判断if not excel_file.exists(): continue并把失败原因写进日志。5.2 现象输出的 csv 里日期全部变成了 16:00 这样的小时或者变成 5 位数字原因Excel 的日期序列号被 pandas 当作数值读入输出时保留原始值。另外如果源文件是 xlsxlrd 引擎默认formatting_infoTrue才能拿到日期格式否则也变成数字。解决读取时强制指定日期列或使用pd.to_datetime转换并设置origin1899-12-30。具体代码见 4.2 节。我习惯在转换前打印 DataFrame 的dtypes看到 float64 的列就要警惕。5.3 现象转换结果里多出一些看不见的 sheet比如“Sheet2 (2)”原因Excel 文件里存在隐藏 sheet。pandas 的sheet_nameNone会读取所有 sheet包括隐藏的。隐藏 sheet 里的数据通常是不准备对外发布的转换出来容易泄露内部计算表。解决读取 sheet 列表时用pd.ExcelFile(excel_path, engineopenpyxl)然后遍历book.worksheets的sheet_state属性过滤掉hidden状态的 sheet。注意.xls文件需要用 xlrd 的sheet.visibility属性。实现如下# 过滤隐藏 sheet from openpyxl import load_workbook wb load_workbook(excel_path, read_onlyTrue) visible_sheets [ws.title for ws in wb.worksheets if ws.sheet_state visible] wb.close()5.4 现象转换结果只有表头没有数据行原因Excel 文件里的“数据区域”可能被误判。比如 sheet 中第一行是空行第二行是表头或者表格在很远的位置前面有大量格式空白。pandas 会自动跳过空行但有时会把空行前的空白当作第一行导致数据丢失。解决读取时指定header0并加skiprows手动跳过前几行。更稳健的做法是先用 openpyxl 探测ws.max_row和ws.max_column确认数据范围。如果文件由导出工具生成通常没有问题手工表格则建议先人工检查一次。5.5 现象内存溢出批量转换到一半进程被杀Killed原因文件太大或者sheet_nameNone读取了所有 sheet 到字典中每个 DataFrame 都驻留内存。加上 4.3 节说的 pandas 内存碎片问题很容易 OOM。解决不要一次sheet_nameNone改用pd.read_excel(..., sheet_namesheet_name_list)逐个读取对超大 sheet 设置usecols只取需要的列循环里del gc.collect()必要时用pd.option_context(mode.chained_assignment, None)关闭链式赋值警告减少副本产生。如果还不行就把文件按 sheet 拆分到独立进程处理用 multiprocessing 的 Pool每个子进程单独分配内存结束即回收。5.6 现象Windows 下文件名带:,?等非法字符写文件失败原因csv 输出文件名使用了 Excel 文件名中的非法字符。比如 Excel 文件名含*这在 Windows 文件系统中不允许。更常见的是文件名以点号结尾如report..xlsx生成 csv 时会变成report..csv创建时报错。解决写文件前用白名单过滤文件名。简单做法是保留字母数字下划线和中文字符其他替换为_。注意保留中文文件名很多业务系统用中文命名不能一棍子打死。import re def safe_filename(name: str) - str: # 允许中文、英文、数字、-、_ 和 .其他替换为下划线 cleaned re.sub(r[^\w\u4e00-\u9fa5.-], _, name, flagsre.UNICODE) # 去除末尾的点和空格防止 Windows 报错 cleaned cleaned.rstrip(. ) or _ return cleaned6. 进阶用法把批量转换变成一条流水线附带校验和增量处理6.1 用 glob 精确匹配不只是*.xlsxrglob(*.xlsx)会把.xlsb和.xls漏掉如果你要同时处理两类后缀可以写两个 glob 然后合并但注意无序性。更精确的做法是用fnmatch或正则匹配文件名。比如只处理文件名包含_2024_的文件用re.search放在循环条件里。我一般先在配置里定义规则列表避免后续扩展时改代码。6.2 结果校验转换完不等于能用转换成功只是第一步下游加载 csv 经常因为空行、缺列、类型不对而 fail。所以我的流水线里会加一个校验函数检查每个输出的 csv 是否满足非空至少 1 行、列数与预期一致、关键字段无缺失、行数比源表少不超过预期。校验通过才把文件移动到“已转出”目录失败则留在“待检查”目录。代码示例如下# validate_csv.py import csv def validate_csv(csv_path: str, expected_cols: int None): with open(csv_path, r, encodingutf-8-sig, newline) as f: reader csv.reader(f) header next(reader, None) if header is None: return False, 空文件 if expected_cols is not None and len(header) ! expected_cols: return False, f列数异常: {len(header)} ! {expected_cols} row_count sum(1 for _ in reader) if row_count 0: return False, 只有表头无数据 return True, f行数 {row_count}这个方法适合最终落地方案因为 csv 的校验比 Excel 更容易自动化下游系统也简单。注意这里的newline是必须的否则 Python 会把\r\n转换成\n导致字符流转义影响空行判断。6.3 增量转换按文件修改时间跳过旧文件批量转换一旦纳入定时任务就面临重复处理的问题。如果不做增量每天全量扫描上千个文件耗费时间且没必要。常见做法是记录每个文件最后成功转换的修改时间戳保存在一个state.json里。每次运行时扫描目录计算出每个文件的mtime如果小于或等于上次成功时间就跳过。import json, hashlib def should_skip(file_path: Path, state: dict) - bool: stat file_path.stat() file_key str(file_path.resolve()) recorded state.get(file_key) if recorded is not None: # 对比修改时间戳和文件大小双保险 if recorded[mtime] stat.st_mtime and recorded[size] stat.st_size: return True return False这个方案的坑在于文件被修改但 mtime 被刻意改回旧值或者文件内容变了但大小和 mtime 都没变就会漏转。更可靠的是计算文件哈希但一万个文件的哈希代价不低。折中方案mtime 大小命中就跳过再用抽样检查兜底比如每次随机抽 5% 的文件计算哈希发现不一致就把整个状态缓存作废。从那以后我每次做批量转换工具都会强制走一遍“单文件成功 → 批量空跑 → 抽样校验 → 增量状态”四步验证流程这套习惯帮我躲过了很多次“转了但数据不对”的返工希望帮到你。本文还有配套的精品资源点击获取