ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pandas数据导出实战:CSV、Excel与MySQL避坑指南

Pandas数据导出实战:CSV、Excel与MySQL避坑指南 在数据分析流程里导数据往往是收尾那一哆嗦。前面辛辛苦苦做完清洗、转换、聚合到最后一步发现导出卡壳或者导出来的文件换个环境就乱码、格式全丢这种事我见过太多次了。Pandas的导出方法看着就那几个函数但真要用的顺手、不出岔子里面有不少值得掰开揉碎讲清楚的细节。这篇文章就基于实际项目经验把Pandas数据导出的常用方法从头到尾梳理一遍。1. 项目背景与核心需求拆解1.1 为什么导出这一步值得单独开一篇讲很多初学者会把重心全放在数据读取和前处理上觉得导出不就是df.to_csv(xxx.csv)嘛一行代码的事。但等到真正做项目、交数据、对接下游系统的时候各种问题就冒出来了字段没对齐、日期格式变了、小数位数多了几位、空值不见了、列顺序错乱、中文变乱码……每一个小问题都会让下游同事找上门来。这次的项目实战记录我特意挑了Pandas数据导出这个主题是因为我最近在一个电商销售分析项目里正好遇到了一个比较典型的场景原始数据是从多个Excel表汇总过来的经过清洗和透视之后需要分别输出给三个不同角色使用——运营要看宽表明细、财务要按特定格式汇总、数据仓库那边要灌入MySQL。同一个DataFrame三种导出需求格式、编码、字段策略全不一样这就逼着我把Pandas的常用导出方法从头到尾梳理压实了一遍。1.2 这次实战主要解决了四类需求日常分析结果的CSV快速导出以及大文件导出时怎么控制内存和性能Excel多格式导出既要单个Sheet、又要多Sheet还要调整列宽格式数据库对接场景下如何用Pandas直接写入MySQL避免一条条insert的笨办法不同下游系统对文件格式有差异化要求时如何用JSON、Parquet等格式高效交接。整篇文章我会使用一个模拟的电商订单销售明细数据集作为演示对象这样可以保证每个方法后面跟的代码和输出结果都是可复现的。这套数据包含了订单号、用户ID、商品类目、销售额、订单日期、收货城市等常见字段比较贴近真实业务。2. 环境准备与前置知识补全2.1 环境版本建议与安装做数据导出之前先把环境盘点一下。我目前使用的版本组合是Python 3.10 Pandas 2.0.3 openpyxl 3.1.2 SQLAlchemy 2.0.19这个组合在性能和数据格式支持上表现比较稳定。如果是从零开始搭环境建议直接创建一个独立的虚拟环境避免不同项目之间包版本冲突。# 创建并激活虚拟环境 python -m venv venv_export_practice # Windows系统激活 venv_export_practice\Scripts\activate # macOS/Linux系统激活 source venv_export_practice/bin/activate # 安装核心依赖 pip install pandas openpyxl xlsxwriter sqlalchemy pymysql这里要强调一个很多人容易踩的坑Pandas的to_excel需要依赖第三方引擎openpyxl和xlsxwriter是两选一还是都装我的建议是都装上。因为openpyxl对Excel 2007以上格式兼容性最好能保留已有的样式而xlsxwriter在写入大量数据时性能更有优势两者侧重点不同后面导出Excel时可以根据需求切换engine参数。2.2 准备一份可复现的演示数据集为了让下面的每个导出方法都能直接跑我准备用Pandas手搓一份订单明细数据。这段代码你也可以直接贴到自己环境里跑一遍后面所有的例子都基于这份DateFrameimport pandas as pd import numpy as np from datetime import datetime, timedelta # 固定随机种子保证每次生成的数据一致方便对照结果 np.random.seed(42) # 生成3000条订单记录 n 3000 order_ids [fORD{str(i).zfill(6)} for i in range(1, n1)] user_ids np.random.randint(10000, 99999, sizen) categories np.random.choice([手机数码, 家用电器, 服饰鞋包, 美妆个护, 食品生鲜], sizen, p[0.25, 0.2, 0.2, 0.15, 0.2]) amounts np.round(np.random.uniform(19.9, 5999.0, sizen), 2) dates [datetime(2024, 1, 1) timedelta(daysint(i)) for i in np.random.randint(0, 180, sizen)] cities np.random.choice([北京, 上海, 广州, 深圳, 杭州, 成都, 武汉, 南京], sizen) payment_methods np.random.choice([微信支付, 支付宝, 银行卡, 花呗], sizen, p[0.4, 0.35, 0.15, 0.1]) df pd.DataFrame({ 订单号: order_ids, 用户ID: user_ids, 商品类目: categories, 销售额: amounts, 订单日期: dates, 收货城市: cities, 支付方式: payment_methods }) # 随机注入一些空值模拟真实数据中的脏数据 df.loc[50, 销售额] np.nan df.loc[200:205, 用户ID] np.nan df.loc[1000, 收货城市] # 看一眼数据结构和前5行 print(df.shape) print(df.head())输出结果大概是3000行7列。我特意注入了几个缺失值这样后面讲缺失值导出策略时更容易对照——缺数据处理不好导出来的文件在别人手上就是隐患。3. to_csv导出最常用的方法里藏着不少细节3.1 to_csv的核心参数拆解CSV是最通用的数据交换格式几乎任何系统都能处理。Pandas的to_csv方法看起来简单但参数非常多真正能影响到下游解析的参数我列一下参数名默认值实战推荐说明sep,视业务而定字段分隔符注意与文件内容里的字符冲突encodingNoneutf-8-sig编码方式utf-8-sig带BOM头Excel打开不乱码indexTrueFalse是否导出索引列绝大多数业务场景不需要headerTrueTrue是否导出列名columnsNone指定列表选择部分列导出调整列顺序na_rep空字符串\\N或NULL空值替代文本对接数仓时常用float_formatNone%.2f浮点数格式控制date_formatNone%Y-%m-%d日期格式化统一口径quoting0按需字段引号规则内容含分隔符时安全3.2 最安全的日常导出姿势日常业务中我推荐一套比较稳妥的写法# 导出前先统一日期格式避免下游解析时因时区或格式问题发飙 df_export df.copy() df_export[订单日期] df_export[订单日期].dt.strftime(%Y-%m-%d) # 销售额保留两位小数 df_export[销售额] pd.to_numeric(df_export[销售额], errorscoerce) # 执行导出 df_export.to_csv( 订单明细_2024上半年.csv, indexFalse, # 不导出索引 encodingutf-8-sig, # 带BOM头的UTF-8Excel打开不乱码 float_format%.2f, # 金额保留两位小数 date_format%Y-%m-%d, # 日期格式统一 na_repNULL # 空值用NULL标识 )这里有几个点新手经常搞不明白。首先是encoding为什么要用utf-8-sig而不是纯utf-8如果你用纯utf-8导出的CSV用Excel双击打开中文大概率是乱码但加上utf-8-sig后文件开头会多一个BOM头通常看不见Excel能据此自动判断编码。而如果文件是要交给Linux下的Python或SQL等工具读取那就要注意了建议去BOM否则读出来的第一列列名里可能带个看不见的\ufeff字符。其次是indexFalse。Pandas的DataFrame天然带一个从0开始的行号索引如果你导出时忘了加indexFalse文件里就会多出一列没有任何列名的行号。下游如果按列名取值这列数据会打乱列顺序的理解。我见过不止一个数据分析师因为漏了这个参数被下游工程师拿着一个多出来的匿名列找上门来。3.3 大文件导出时的性能与内存控制当DataFrame规模大比如几百万行甚至上千万行时一次性整体to_csv会占用大量内存而且写入速度受限于单线程IO。实测下来如果数据量超过500万行最好不要直接用df.to_csv一次写完。我的经验做法是分批写入利用to_csv的mode参数和header参数配合# 分块导出每10万行写一次 chunk_size 100000 output_path 大订单表_分块导出.csv # 第一块写入表头和第一批数据 df.iloc[:chunk_size].to_csv( output_path, indexFalse, encodingutf-8-sig, modew # w模式覆盖写入 ) # 后续批次追加模式headerFalse避免重复写表头 for start in range(chunk_size, len(df), chunk_size): end min(start chunk_size, len(df)) df.iloc[start:end].to_csv( output_path, indexFalse, encodingutf-8-sig, modea, # a模式追加写入 headerFalse # 不写表头 )这种写法的优势有两点第一每一批内存中只需保留10万行数据总体不会造成内存峰值过大第二写入过程中如果某一批次失败已经成功的批次数据还在文件里排查也方便。如果是单机内存明明够用但速度慢还可以考虑换用polars或者modin这类并行计算框架来提速——但别一上来就升级工具先确认瓶颈究竟在磁盘IO还是CPU计算。3.4 关于压缩参数的两个经验CSV如果文件特别大可以用压缩格式节省磁盘空间。Pandas原生支持gzip、bz2、zip、xz四种压缩df_export.to_csv(订单明细.csv.gz, indexFalse, compressiongzip) df_export.to_csv(订单明细.csv.zip, indexFalse, compressionzip)实测中gzip压缩率最高写入速度也还可以。zip格式的特殊之处是压缩包里的文件名默认是订单明细.csv有些业务系统要求固定文件名这对上传统计很友好。需要注意如果文件给非技术同事用尽量别压缩他们真的可能不会解压或者解压后找不到文件。4. to_excel导出多Sheet与格式控制是高频需求4.1 基础导出与两种引擎的差异Excel算得上最贴近业务人员使用习惯的格式。Pandas的to_excel底层需要引擎支持常见的是openpyxl和xlsxwriter两者区别在于openpyxl可处理xlsx文件支持读写如果需要对已有Excel文件做追加写入时必须用它xlsxwriter专注于写入写入速度快且支持的格式控制更加丰富但不支持读取。实际开发时我通常这么选如果是从零新建Excel且要做许多格式美化优先xlsxwriter如果是往已有Excel某个Sheet里追加数据那么只能选openpyxl否则会报错。基础导出的写法# 方式一默认引擎通常是openpyxl df.to_excel(订单明细.xlsx, indexFalse, sheet_name订单明细) # 方式二指定xlsxwriter引擎写入大文件时性能更好 df.to_excel(订单明细.xlsx, indexFalse, sheet_name订单明细, enginexlsxwriter)4.2 一个Excel文件写入多个Sheet实际业务中经常需要把一个数据集拆成多个Sheet比如按月份拆分、按类目拆分这样运营同事在一个Excel里就能切换查看。Pandas提供ExcelWriter对象来实现多Sheet写入output_path 订单明细_多Sheet汇总.xlsx # 方法一使用ExcelWriter上下文管理器 with pd.ExcelWriter(output_path, engineopenpyxl) as writer: # 全量数据 df.to_excel(writer, sheet_name全量数据, indexFalse) # 按月拆分 for month in [2024-01, 2024-02, 2024-03, 2024-04, 2024-05, 2024-06]: month_df df[df[订单日期].dt.strftime(%Y-%m) month] if not month_df.empty: month_df.to_excel(writer, sheet_namemonth, indexFalse) # 按类目汇总 category_summary df.groupby(商品类目)[销售额].agg([sum, mean, count]).reset_index() category_summary.columns [商品类目, 总销售额, 平均销售额, 订单数] category_summary[总销售额] category_summary[总销售额].round(2) category_summary[平均销售额] category_summary[平均销售额].round(2) category_summary.to_excel(writer, sheet_name类目汇总, indexFalse)需要注意两个坑第一个Sheet名称最长31个字符、不能包含/\*?[]也不能命名为History之类的保留名遇到这些情况要用rename做映射。第二个ExcelWriter在同一个上下文里既可以写入普通DataFrame也可以在后面继续用to_excel追加但不要尝试用同一个writer对象写多个不同引擎格式的文件这会导致内部状态混乱。4.3 自动调整列宽和表格样式Pandas原生的to_excel是没有任何样式优化的——导出的Excel数字确实是数字但列宽是默认宽度表头也没有加粗。直接把这个文件发给领导或者运营效果会很平逼格全无。如果想要做出交出去就能当成品看的Excel文件有一个通用的处理思路先用Pandas把数据写入Sheet再用openpyxl读取文件进行样式调整最后保存。import openpyxl from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter # 第一步Pandas写入数据 excel_path 订单明细_格式化.xlsx with pd.ExcelWriter(excel_path, engineopenpyxl) as writer: df.to_excel(writer, sheet_name订单明细, indexFalse) # 第二步用openpyxl调整样式 wb openpyxl.load_workbook(excel_path) ws wb[订单明细] # 设置表头样式加粗、背景色、居中 header_font Font(name微软雅黑, boldTrue, size10, colorFFFFFF) header_fill PatternFill(start_color4472C4, end_color4472C4, fill_typesolid) center_alignment Alignment(horizontalcenter, verticalcenter) for col_idx, cell in enumerate(ws[1], start1): cell.font header_font cell.fill header_fill cell.alignment center_alignment # 自动调整列宽根据内容长度估算 for col_idx in range(1, ws.max_column 1): max_len 0 column_letter get_column_letter(col_idx) for row in ws.iter_rows(min_colcol_idx, max_colcol_idx, values_onlyTrue): for cell_value in row: if cell_value is not None: # 中文字符宽度按2个单位计英文字符按1个单位计 value_str str(cell_value) length sum(2 if ord(ch) 127 else 1 for ch in value_str) if length max_len: max_len length adjusted_width min(max_len 6, 50) # 上限50避免列过宽 ws.column_dimensions[column_letter].width adjusted_width # 冻结首行方便滚动查看数据 ws.freeze_panes A2 wb.save(excel_path)这段代码我每次做运营周报导出都会用基本成了标准模板。值得说明的坑点下面两个一是列宽估算要区别中英文宽度全角字符比如中文显示宽度是半角的两倍如果不加权处理收货城市这种短中文列名会被塞得很窄。二是调整列宽的计算如果数据量很大建议只遍历前100行做采样估算不要一次性遍历全部几百万行。只做样式调整的话遍历全量数据耗时实在不划算。4.4 Excel导出时数据类型自动丢失的坑这是所有Excel导出场景里最常见的问题之一。业务场景里我发现用户ID、订单号这类编号列常常被Excel自动转成科学计数法或者变成数值类型后精度丢失。比如用户ID是1234567890123456789直接导出Excel后可能就变成了1.23457E18末尾几位直接被抹成0。Pandas导出时会把数据写为真实数值Excel打开时会按单元格格式做显示大数字默认科学计数。解决思路是把编号列转成字符串类型并且最好在前面加一个不可见前缀让它完全按文本处理或者让Excel识别为文本格式。df_export df.copy() # 关键转成字符串后再导出 df_export[用户ID] df_export[用户ID].astype(str) df_export[订单号] df_export[订单号].astype(str) # 如果不想改变数据内容又想强制Excel按文本显示可以加特殊前缀 # df_export[用户ID] \ df_export[用户ID] \ # 但这样数据值本身变成了Excel公式下游解析时会很麻烦不推荐跨系统使用 df_export.to_excel(订单明细_文本ID.xlsx, indexFalse)我的处理经验是如果能保证ID本身就适合当文本用就用astype(str)转换如果下游需要用Excel透视或关联那么字符串类型的ID也能正常处理没必要强转尽量不要用等号拼接那种公式写法虽然Excel界面显示正常但用Python的pandas.read_excel去读的时候会读到公式字符串而不是值。5. to_sql导出直接对接MySQL数据库5.1 为什么推荐用to_sql而不是逐行insert在做数据工程对接时经常需要把Pandas处理好的结果表写入数据库。很多初学者第一反应是用pymysql手工写循环insert这在大数据量下性能很差几百行还好几万行就开始吃顿严重的。Pandas的to_sql可以帮你直接建表、批量写入。底层执行的是批量INSERT操作通过SQLAlchemy连接效率大幅提升。实测写入10万行数据到MySQL循环insert可能要40多秒而to_sql只需2到3秒性能差别非常明显。5.2 to_sql的基础用法与参数解读先安装并确认依赖pip install sqlalchemy pymysql然后创建数据库连接from sqlalchemy import create_engine # 创建数据库连接字符串 # 格式mysqlpymysql://用户名:密码地址:端口/数据库名?charsetutf8mb4 engine create_engine( mysqlpymysql://root:passwordlocalhost:3306/data_analysis?charsetutf8mb4, echoFalse # 设为True时会在控制台打印SQL日志调试用 ) # to_sql基本用法 df.to_sql( nameorder_detail, # 目标表名 conengine, # 数据库连接 if_existsappend, # 表已存在时的策略fail/append/replace indexFalse, # 不写入索引 dtype{ 订单号: String(20), 用户ID: String(20), 销售额: Float(10, 2) } )这里几个关键参数if_existsreplace会先DROP表再重建注意这会清空原有数据if_existsappend则是在已有表后面追加数据if_existsfail遇到表存在就直接报错。5.3 设置主键与字段类型的两种方式to_sql有个不太方便的地方默认创建目标表时它不会帮你自动设置主键也不会自动优化索引。如果目标表需要按照订单号做唯一约束你得对建表逻辑单独控制。我常用的处理方式分两种情况第一种目标表本来就已经在数据库里建好了字段类型、主键、索引都设计完毕那Pandas这边就直接append即可不需要关心dtype参数。第二种首次建表希望连同主键一起建出来。这时可以通过dtype传入SQLAlchemy类型再配合自定义操作完成建表。from sqlalchemy.types import String, Float, DateTime, Integer # 先删掉旧表模拟replace逻辑 # 注意真实项目中慎用务必确认数据可重新生成再接此操作 # engine.execute(DROP TABLE IF EXISTS order_detail_custom) # 用Pandas建表同时指定字段类型 df.to_sql( nameorder_detail_custom, conengine, if_existsreplace, indexFalse, dtype{ 订单号: String(20), 用户ID: String(20), 商品类目: String(50), 销售额: Float(10, 2), 订单日期: DateTime, 收货城市: String(50), 支付方式: String(20) } ) # 通过SQL命令追加主键如果之前没设置过 with engine.connect() as conn: conn.execute(ALTER TABLE order_detail_custom ADD PRIMARY KEY (订单号))实际上用String类型保存订单号完全合理因为订单号通常不会参与数值运算但可能会用来关联表走索引没有问题。反过来如果订单号在源系统里其实是BigInt你硬要转成VARCHAR后续join就要走隐式类型转换性能反而变差。所以建表字段类型还是要跟着业务实际走不要无脑加引号。5.4 数据清洗后再入库避免下游SQL爆错直接用to_sql写库之前一定要先确认DataFrame里没有无法被数据库接受的值。例如infinity无穷大值如果数据里有np.inf写入MySQL会报错NaN空值默认写进数据库就成了NULL可能要你明确允许NULL的字段才能写入日期时间时区带时区的datetime某些MySQL版本写不进去。我在项目里常用的清洗模板import numpy as np df_to_db df.copy() # 1. 将无穷大值替换为NULL df_to_db df_to_db.replace([np.inf, -np.inf], np.nan) # 2. 处理空城市字段空字符串容易导致下游统计混乱改成NULL df_to_db.loc[df_to_db[收货城市] , 收货城市] None # 3. 金额字段重新检查一遍确保不是object类型 df_to_db[销售额] pd.to_numeric(df_to_db[销售额], errorscoerce) # 4. 再次检查总行数是否有变化 print(f清洗前: {len(df)} 行, 清洗后: {len(df_to_db)} 行) # 5. 写库 df_to_db.to_sql( nameorder_detail_clean, conengine, if_existsappend, indexFalse )这里有个很重要的排查方法to_sql报错后别急着埋怨数据库先把出问题的行捞出来看看。一般来说报错信息里会带具体行和字段你可以在Python里逐一排查字段类型。一个笨但非常有效的办法是打印出每一列的非空值数量和唯一值类型for col in df_to_db.columns: print(f{col}: dtype{df_to_db[col].dtype}, non-null{df_to_db[col].notna().sum()}, fn_unique{df_to_db[col].nunique()})这样能快速定位到是哪一列出了问题。很多时候就是某列混入了字符串类型的数字或者时间格式不统一。6. 其他格式导出JSON与Parquet的使用场景6.1 to_json适合接口对接和NoSQL存储JSON格式在接口对接、文档型数据库存储方面非常常用。Pandas的to_json可以导出为多种JSON结构比较常用的有orientrecords列表套字典每行一个对象最常用orientsplit分列存储索引、列名、数据orientrecords linesTrueJSON Lines格式每行一个独立JSON对象适合日志型数据流式处理。导出示例# 常规记录格式 df.to_json(订单明细.json, orientrecords, force_asciiFalse, indent2) # JSON Lines格式每行一条记录 df.to_json(订单明细.jsonl, orientrecords, linesTrue, force_asciiFalse)这里重点说下force_asciiFalse参数。默认情况下Pandas导出JSON时会把中文转成\uXXXX这种Unicode转义序列文件小倒还好文件大了非常可读性差也不方便排查。设成False后中文就能原样输出文件体积也会小不少下游如果按UTF-8解析完全没问题。需要留个心眼的是JSON格式没有内建的类型区分比如Pandas的Timestamp类型导成JSON后会自动变成字符串下游解析时如果有时间运算需求一定要按ISO 8601字符串做转换。这个不算bug属于JSON格式本身的限制。6.2 to_parquet列式存储在大数据场景中的优势Parquet是列式存储格式在Spark、Hive等大数据组件中几乎是标准格式。它可以保留数据类型、支持snappy压缩还能按列裁剪极大的减少IO开销。如果你的下游是数仓或需要重复读多次做聚合分析导成Parquet远比CSV合适。Pandas导出Parquet需要安装pyarrow或fastparquetpip install pyarrow然后一行代码导出df.to_parquet(订单明细.parquet, indexFalse, compressionsnappy)实测中同样一份100万行的订单表CSV文件大约80MBParquet只需要约15MB而且从Parquet读回Pandas的速度比读CSV快3到5倍。这是因为Parquet保留了每一列的物理类型读入时不需要像CSV那样再做类型推断。6.3 to_pickle最快但最封闭的格式如果只是自己中间调试或者保存中间处理结果Pickle是最快方案——Pandas原生的序列化读完直接还原成DataFrame类型完全保留。df.to_pickle(订单明细.pickle) df_loaded pd.read_pickle(订单明细.pickle)但是Pickle格式存在明显的局限性只能Python读取且存在反序列化代码执行的安全隐患。别人给你一个来路不明的pickle文件load之前要三思。我的习惯是pickle只用于自己本机会话间的短期缓存但凡要跨机器、跨语言、跨系统传输一律用Parquet或CSV。7. 导出实战一个完整的一鱼三吃案例7.1 场景说明一份数据三种下游三种格式为了把上面这些方法串起来我模拟一个完整场景。假设我手上是3000行订单数据现在需要完成三个任务给运营同事一份带格式的Excel包含全量明细、月度汇总、类目汇总三个Sheet给数据仓库导一份清洗后的CSV包含订单号、用户ID、销售额、订单日期、商品类目、收货城市字段用英文命名空值统一为NULL把数据写入MySQL的order_detail表后续BI报表从中取数。这个需求非常典型。运营不需要也不会看数据库但他们需要一张可以直接筛选的Excel数仓那边对字段名、空值和编码有严格规范BI报表则依赖库表。7.2 数据预处理先统一口径再想导出所有导出任务共用一个预处理流程这一步非常关键避免每个导出代码里都重复处理一遍也避免相同的数据在不同文件里口径不一致。import re # 数据预处理 def preprocess_order_data(raw_df): df_clean raw_df.copy() # 1. 统一日期为datetime类型非法值置为NaT df_clean[订单日期] pd.to_datetime(df_clean[订单日期], errorscoerce) # 2. 金额转数值非法值置为NaN df_clean[销售额] pd.to_numeric(df_clean[销售额], errorscoerce) # 3. 用户ID转字符串并去掉可能存在的空格 df_clean[用户ID] df_clean[用户ID].astype(str).str.strip() # 空字符串和nan统一转为缺失值表示 df_clean.loc[df_clean[用户ID].isin([nan, None, ]), 用户ID] None # 4. 商品类目去除首尾空格 df_clean[商品类目] df_clean[商品类目].astype(str).str.strip() # 5. 收货城市空字符串转NaN df_clean.loc[df_clean[收货城市] , 收货城市] None # 6. 删除全空行 df_clean df_clean.dropna(howall) return df_clean df_clean preprocess_order_data(df) print(df_clean.info())预处理完用info()看一下每列的非空值数量。该是3000行的订单量如果清洗后剩2990多行说明确实有重复或全空行被剔掉这种差异要记录在案方便向业务交代。7.3 第一条线生成运营Excel报表运营侧那份Excel我加上更多实用的处理逻辑包括表头加粗和底色填充金额列保留两位小数对总销售额做色彩刻度条件格式。with pd.ExcelWriter(运营报表_2024上半年.xlsx, engineopenpyxl) as writer: # Sheet1全量数据 df_clean.to_excel(writer, sheet_name全量明细, indexFalse) # Sheet2月度汇总 df_clean[月份] df_clean[订单日期].dt.strftime(%Y-%m) monthly df_clean.groupby(月份).agg( 订单数(订单号, count), 销售额(销售额, sum), 客单价(销售额, mean), 用户数(用户ID, nunique) ).reset_index() monthly[客单价] monthly[客单价].round(2) monthly.to_excel(writer, sheet_name月度汇总, indexFalse) # Sheet3城市类目交叉汇总 city_cat df_clean.pivot_table( index收货城市, columns商品类目, values销售额, aggfuncsum, fill_value0 ).reset_index() city_cat.to_excel(writer, sheet_name城市类目交叉, indexFalse)有些细节可能你自己试了才会发现。pivot_table生成的列名是多级索引比如(手机数码,)这样带着元组的列名直接导出Excel会很难看。上面的写法好在aggfunc是标量函数列名保持原样不会产生多层索引。如果你真的处理了多层列名的数据可以在导出前手动把columns改成单层字符串。7.4 第二条线导出给数仓的清洗CSV数仓对接类文件的字段名建议用英文并保持小写下划线风格。这种习惯可以避免下游写SQL时在字段上加各类奇怪的反引号。# 构建数仓用表 df_warehouse df_clean.rename(columns{ 订单号: order_id, 用户ID: user_id, 商品类目: category, 销售额: sales_amount, 订单日期: order_date, 收货城市: city, 支付方式: payment_method }) # 选择指定列并排序 df_warehouse df_warehouse[[order_id, user_id, category, sales_amount, order_date, city]] df_warehouse df_warehouse.sort_values(order_date) # 导出CSVutf-8无BOM供Linux下的数仓任务读取 df_warehouse.to_csv( order_detail_for_dw.csv, indexFalse, encodingutf-8, # 注意这里是纯utf-8不带BOM数仓工具更友好 na_repNULL, date_format%Y-%m-%d, float_format%.2f )这步我尤其要提醒编码问题给数仓的文件用utf-8无BOM给Excel用户用utf-8-sig。很多人一个编码走天下导致到处乱码。写之前想清楚文件最终被什么工具打开编码方式就基本不会错。7.5 第三条线同步MySQL库表同步数据库这一手要注意字段名英文、类型明确、日期作为date类型导入。如果MySQL服务不在本地还要考虑网络传输效率和批量大小。# 因为是全量刷新先delete后insert比replace更安全 # 用事务包裹避免删了但没插进去的中间状态 from sqlalchemy import text table_name order_detail_report with engine.begin() as conn: # 清空老数据 conn.execute(text(fDELETE FROM {table_name})) # 然后写入新数据 df_warehouse.to_sql( nametable_name, conengine, if_existsappend, indexFalse, chunksize2000, # 分批写入每批2000行 dtype{ order_id: String(20), user_id: String(20), category: String(50), sales_amount: Float, order_date: Date, city: String(50) } )这里加了chunksize参数作用是把一个大DataFrame拆成多个小批量分多次写入。直接一坨写入是能跑但中途一个网络闪断会回滚整个批次。分小批之后虽然总时间会略增但单批失败的影响范围变小也方便日志定位。8. 常见报错与排查经验记录8.1 Excel写入时的PermissionError症状PermissionError: [Errno 13] Permission denied: xxx.xlsx排查方向这个报错90%是因为你要写入的Excel文件正被Excel程序打开占用。Windows系统尤其常见文件被Excel进程锁定后Python侧写入会被拒绝。常规处理关掉正在打开这个Excel文件的窗口如果是公司共享盘里文件被同事打开则要协调或者另存一个新的文件名写代码时在导出前加一个try/except提示用户关闭文件。import os output_path 订单明细.xlsx try: df.to_excel(output_path, indexFalse) except PermissionError: raise SystemExit(f导出失败请先关闭文件 {output_path}再重新运行脚本)8.2 to_sql写入中文乱码症状写入MySQL后中文显示为问号或者乱码。排查方向主要是连接字符串和数据库表两级编码问题。如果连接字符串里没有指定charsetutf8mb4而数据库表默认编码是latin1写入时就会出现乱码。注意utf8mb4和utf8也有区别比如emoji表情和一些生僻字utf8就存不了必须用utf8mb4。处理方式# 1. 连接串里明确指定utf8mb4 engine create_engine( mysqlpymysql://root:passwordlocalhost:3306/data_analysis?charsetutf8mb4 ) # 2. 建表语句中指定表编码 with engine.connect() as conn: conn.execute(text( CREATE TABLE IF NOT EXISTS order_detail_report ( order_id VARCHAR(20), user_id VARCHAR(20), category VARCHAR(50), sales_amount FLOAT, order_date DATE, city VARCHAR(50) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci ))8.3 导出Excel后数值列变成科学计数法症状用户ID列导出后变成类似1.23457E17。排查方向说明该列在DataFrame中的数据类型是int64或float64Excel打开时按数值类型做默认显示。如果这一列实际是编号用户ID/订单号而不是数值导出前要转成字符串。如果列值与数字特征无关转字符串最安全df[订单号] df[订单号].astype(str) df[用户ID] df[用户ID].astype(str)8.4 csv文件打开中文乱码但数据没丢症状用Excel打开CSV中文显示乱码但用Notepad或者Python打开是正常的。排查方向大概率是编码不是utf-8-sig。解决方案就是导出时使用带BOM的UTF-8df.to_csv(订单明细.csv, indexFalse, encodingutf-8-sig)8.5 导出的Parquet文件Spark读不了症状用Pandas的pyarrow引擎导出的parquet文件Spark读的时候报TypeError或者schema问题。排查方向Pandas 2.0以后版本在底层转换中可能出现某些时间类型如Timestamp(ns)与Spark默认的timestamp类型不兼容。如果你的下游是大数据平台建议统一用coerce_timestampsus参数导出df.to_parquet( 订单明细.parquet, indexFalse, enginepyarrow, coerce_timestampsus # 统一微秒时间精度兼容Spark )这类问题往往带有版本耦合性升级pandas或pyarrow后行为可能变化。我的建议是在公司内部约定一个统一的版本组合锁定关键依赖版本导出时先拿小数据测试打通再全量作业。8.6 各导出方法常见问题速查问题现象可能原因解决方案CSV中文乱码Excel打开编码未带BOMencodingutf-8-sigCSV第一列多出无名列index未设FalseindexFalse日期导出后变成时间戳date格式被Excel转换先dt.strftime再导出Excel科学计数法显示长IDID列未转字符串astype(str)to_sql写入时中文变问号连接/表编码不是utf8mb4连接串加上charsetutf8mb4to_sql报DataError/Out of range数值长度超字段定义用dtype参数缩小精度或改宽松字段打开xlsx提示文件损坏导出过程中程序被中断使用with打开ExcelWriter保证正常保存大CSV导出内存暴涨数据量过大一次性写分批追加写Parquet文件Spark读schema冲突时间精度ns与Spark不兼容coerce_timestampsus9. 项目复盘导出策略选择的核心决策依据9.1 按文件格式优先级的决策树用了这么多年我慢慢总结出一套选择导出格式的决策逻辑。核心判断标准有四个数据量级、下游工具、是否需要保留类型、是否需要人工阅读。数据要给人看的Excel或CSVutf-8-sig带格式优先Excel数据是给程序读的小数据用CSVutf-8无BOM大数据用Parquet数据要入数据库直接用to_sql不要走文件中转除非需要留档数据是中间态暂时保存先用pickle或Parquet快且省空间数据要走API对接JSON转records格式注意将时间统一为ISO8601字符串。9.2 导出前必须检查的三道关卡每次导出之前我习惯先用代码自查三件事相当于质量门禁# 关卡1字段口径 expected_columns [订单号, 用户ID, 商品类目, 销售额, 订单日期] missing_cols set(expected_columns) - set(df.columns) if missing_cols: raise ValueError(f缺少必要字段: {missing_cols}) # 关卡2行数记录与反馈 row_count_before len(df) # 导出前的那些dropna去重等操作如果改动行数必须知道 if len(df) ! row_count_before: print(f数据集行数变化: {row_count_before} - {len(df)}) # 关卡3对关键数值列做范围合理性抽取 for col in [销售额]: if pd.api.types.is_numeric_dtype(df[col]): print(f{col}min{df[col].min()}, max{df[col].max()}, mean{df[col].mean():.2f})三道关卡全过了再导出。宁可多写几行检查代码也不要让一份带基础错漏的文件流传到同事手上。9.3 导出这项工作容易被低估的价值很多新人会觉得Pandas导出是最不值钱的技术活不值得写文章总结。但真实项目里一个数据文件导出格式不合适、字段口径不统一、文件名没有日期都可能让下游多出数小时的返工时间。我见过数据分析师在一个取数需求上卡了三天最后发现问题是上游给的文件CSV用了GBK编码而脚本始终按utf-8读这种低级错误在团队里反复上演。把导出的方法、参数、注意事项沉淀下来建立一个导出规范文档是我在团队里最推荐做的事。规范到位了后面的沟通成本能降一个量级。10. 一些在实战中沉淀下来的小技巧10.1 利用with语句管理ExcelWriter防止写一半文件损坏用ExcelWriter时一定把它放进上下文管理器里。如果不加with万一脚本中途报错退出writer对象不会正常closeExcel文件可能只写了一半打不开且无从修复。# ❌ 容易踩坑写法 writer pd.ExcelWriter(output.xlsx, engineopenpyxl) df1.to_excel(writer, sheet_nameSheet1, indexFalse) # 中途如果某行代码抛异常writer没关闭output.xlsx可能不完整 # ✅ 安全写法 with pd.ExcelWriter(output.xlsx, engineopenpyxl) as writer: df1.to_excel(writer, sheet_nameSheet1, indexFalse)10.2 导出文件名带上业务日期避免覆盖旧文件文件命名是个看似不起眼但在协作中极其重要的细节。建议统一为业务类型_数据范围_导出日期.后缀from datetime import date today_str date.today().strftime(%Y%m%d) export_filename f订单明细_2024上半年_{today_str}.xlsx df.to_excel(export_filename, indexFalse)这样的好处是一来容易回溯版本二来不会因为重跑覆盖掉上一次交付给业务方的文件业务方如果发现问题还能对照日期。10.3 大批量导出时保留一份原始数据副本在跑数据整理脚本时我习惯在脚本开头先把原始数据备份一份df_raw.to_pickle(原始数据备份.pkl)这样做是因为真实数据经常会因为格式转换、缺失值处理、类型强转造成不可逆的改动。万一后面的处理逻辑有误直接从pickle备份恢复比重新读取源文件重跑省时省力。10.4 Excel中看起来是数字但实际上不是的排查导出的Excel在业务方手里偶尔会遇到求和为0、vlookup匹配不到这种问题很大程度上是因为某些列里的数字被存成了文本。如果担心这个导出前可以用程序做一次强制检测for col in [销售额]: non_numeric pd.to_numeric(df[col], errorscoerce).isna() if non_numeric.any(): wrong_rows df.loc[non_numeric].index.tolist()[:5] print(f警告列 {col} 存在非数值内容示例行索引: {wrong_rows})提前把这层处理掉交付出去的Excel才能让业务方用得省心一些。11. 收尾数据导出的本质是让数据在合适的位置发挥价值如果用一句话总结这份经历那就是数据导出的本质不是把DataFrame变成文件而是让你处理好的数据能准确、高效地流转到需要它的地方去。回头看这次实战项目里做的三件事——一份带格式的Excel给运营看板用、一份清洗后的CSV进数仓、一份MySQL表给BI报表取数——它们的差异不在Pandas API的熟练程度而在于你是否能站在下游使用者的角度思考问题是否对各种参数的形成机制烂熟于心。从实际踩坑复盘的角度讲我发现很多让人头疼的导出场景都是因为一开始没有花5分钟想清楚这份文件最终会被谁打开、用什么工具读、字段该怎么命名。如果你一开始能把这几个问题想明白后面大部分的乱码、格式错乱、类型丢失和编码问题根本就不会出现。前阵子和团队里的新人聊到这个话题他说了一句让我印象很深的话以前觉得导出数据就是把DataFrame里的东西放到文件里现在才知道让文件在别人手上好读、好看、好用才是真正的一门手艺。我觉得这话说得很到位。
返回列表