如果你跟我一样是靠“Python 100天”这条路一步步走过来的,大概在第23天会遇到一个绕不开的坎:读写CSV文件。CSV全称Comma-Separated Values,中文常叫逗号分隔值。说它是数据处理与交换的基础一点都不夸张——几乎每个和数据打交道的人,电脑里都堆着一堆CSV文件。不管是从Excel里导出报表、从Kaggle下载数据集,还是给模型准备训练样本,CSV总是那个最通用、最不挑人的中间格式。
这一天内容其实不难,但坑是真不少。我见过不少初学者用open()加split(",")硬切CSV,结果被引号里的逗号折磨到怀疑人生;也见过有人把Excel另存为CSV后打开发现中文全是乱码,气到想砸电脑。这篇就把我在实际项目中处理CSV时用到的完整经验拆开讲清楚,从标准库到pandas,从编码到性能,适合刚学完Python语法、准备往数据处理方向走的人,也适合已经写了很久代码但一直被CSV小问题磕绊的朋友。
1. 先搞明白CSV到底是什么:一个格式约定,不是一个程序
很多教程一上来就教代码,但我觉得先弄清CSV的结构更重要。你以为CSV就是“用逗号分隔的文本”,这话对,但只说对了一半。真正常见的CSV里有分隔符、有引号、有换行,这些细节才是决定你读写代码是否会崩的关键。
1.1 一个CSV文件拆开来看
拿最常见的场景举例:你从电商后台导出一份订单数据,Excel里看到的是规整的表格,但用记事本打开CSV文件,看到的东西长这样:
order_id,customer_name,amount,note 1001,张三,299.00,"已发货, 备注加急" 1002,李四,59.90,正常 1003,王五,129.00,"客户说 晚点再送"注意第三行,note字段的值是已发货, 备注加急,里面带了一个逗号。如果按“逗号切分”的思路去读,这一行就会被硬生生切成5段,数据直接错位。
再看第四行,customer_name对应的内容是客户说加换行再加晚点再送,也就是说CSV字段内部可以包含换行符,前提是它被引号包起来了。这种文件用记事本或Excel打开肉眼根本看不出问题,但用split(",")处理就彻底完蛋。
1.2 CSV的优点和先天不足
CSV能成为数据交换的通用语言,靠的是极简。它没有专有格式的版权限制,任何编程语言、任何操作系统拿个文本编辑器都能打开;它甚至不需要数据库服务,一个文件拷走就完成了数据迁移。对于跨系统、跨团队的数据交换,CSV比Excel的xlsx格式友好太多——xlsx本质上是一个压缩包,内部由一堆XML组成,处理起来开销大得多。
但CSV也有先天不足:它没有数据类型定义,所有值都是字符串,数字、日期、布尔值全靠接收方自己解析;它没有字符编码标准,同一个文件在不同国家可能是UTF-8、GBK或Latin-1;它对复杂嵌套结构支持极差,那个“字段里有换行”的例子就够让人头疼的。所以CSV适合做表格式数据的交换,但别指望像JSON那样表达嵌套对象。
1.3 为什么Python处理CSV这么顺
Python能成为数据处理领域的顶流,很大程度要归功于标准库里的csv模块。它不像某些语言需要四处找第三方库,也不像手写解析那样容易漏掉边界情况,而是把RFC 4180(CSV格式的规范定义)里的规则都封装好了。更关键的是,Python生态里还有pandas这个重型武器,read_csv一行就能把几十万行数据读成DataFrame,后续筛选、聚合、去重都非常方便。
所以这章的核心思路是:优先用csv模块处理标准CSV,数据量大了再上pandas,这两条路走熟了,等于把CSV读写的地基打牢了。
2. 标准库csv模块:最原生的读写方式
标准库csv模块是处理CSV的第一选择,因为不需要安装任何额外依赖,官方也一直在维护。我平时写脚本,只要CSV文件结构不太诡异,都用它。
2.1 用csv.writer写入一个文件
最基础的写入方式是这样:
import csv rows = [ ["order_id", "customer_name", "amount", "note"], [1001, "张三", 299.00, "已发货, 备注加急"], [1002, "李四", 59.90, "正常"], ] with open("orders.csv", "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) for row in rows: writer.writerow(row)这里有两个细节值得单独说。
第一,encoding="utf-8"。如果文件里只有英文,不写编码也没事;一旦有中文,就必须明确指定编码。这个我后面会用一整章讲,因为涉及Excel兼容性,坑特别多。
第二,newline=""。这是比编码还隐蔽的坑。如果你不加newline="",在Windows上打开生成的CSV,会发现每两行之间多了一个空行。原因是csv.writer默认用\r\n作为行结束符,而文件对象本身又做了一次换行转换,结果就是重复换行。加上newline=""可以让写入的换行完全由csv模块控制,不再叠加系统默认行为。
2.2 用csv.reader读取内容
读取同样很简单:
import csv with open("orders.csv", encoding="utf-8", newline="") as f: reader = csv.reader(f) for row in reader: print(row)输出结果会是:
['order_id', 'customer_name', 'amount', 'note'] ['1001', '张三', '299.0', '已发货, 备注加急'] ['1002', '李四', '59.9', '正常']注意,所有字段读出来都是字符串。你看到299.0不是浮点数,它是字符串"299.0"。这一点初学者特别容易忽略,后面做计算前要记得用float()或int()转换。
csv.reader返回的是一个迭代器,这意味着你不能用len(reader)直接拿到总行数,只能遍历它。如果确实需要行数,可以先用list()转成列表,或者单独统计。
2.3 几个容易错过的参数:delimiter、quotechar、lineterminator
csv.reader和csv.writer的核心参数就那么几个,但搞懂它们的含义能省掉大量排查时间。
| 参数 | 作用 | 默认值 | 常见坑 |
|---|---|---|---|
delimiter | 字段分隔符 | , | 制表符分隔的TSV文件需要改成\t |
quotechar | 包裹包含特殊字符字段的引号 | " | 数据里本身带了引号时需要转义处理 |
lineterminator | 写入时的行结束符 | \r\n | 在Linux上想生成LF结尾文件时改成\n |
skipinitialspace | 忽略分隔符后面的空格 | False | 有些工具导出的CSV是", "分隔 |
escapechar | 转义字符 | None | 引号内的引号可以用它处理 |
举例,如果你拿到一个制表符分割的TSV文件:
with open("data.tsv", encoding="utf-8", newline="") as f: reader = csv.reader(f, delimiter="\t") for row in reader: print(row)再举例,如果CSV数据里的字段是"a", "b", "c"这种带空格的写法,不处理会读成' a'、' b'、' c',这时候把skipinitialspace=True设上就干净了。
2.4 把CSV内容写入字符串而不是文件
有时候你不想生成实体文件,而是想生成一个字符串传给HTTP接口,或者拼到邮件正文里。csv模块也支持这种玩法,用io.StringIO()接管:
import csv import io output = io.StringIO() writer = csv.writer(output) writer.writerow(["name", "score"]) writer.writerow(["Alice", 88]) csv_string = output.getvalue() print(csv_string)有了这个技巧,你可以把CSV内容作为API响应返回,我就不止一次在处理报表下载接口时这样写过:“内存里拼好CSV字符串,再通过响应头触发浏览器下载。”配合Content-Disposition: attachment,用户点击按钮就能拿到一个真正的CSV文件,完全不用在服务器磁盘上留下临时文件。
3. 编码问题和Excel的相爱相杀
说到编码,这是CSV读写里最琐碎、也最让新手崩溃的部分。明明在Python里读得好好的,用Excel打开就是乱码;明明用Excel打不开别人的CSV,用记事本却能看。原因都在编码。
3.1 UTF-8是标准,但Excel不这么想
现代Linux和macOS系统默认使用UTF-8编码,Python 3打开文本文件时不指定编码,默认就是UTF-8。但Windows版的Excel处理CSV文件时,默认会用系统本地编码去猜,在中文系统上通常是GBK。
于是出现了一个经典场景:你把一个UTF-8编码的CSV发给同事,同事双击用Excel打开,看到满屏乱码。这时候你不需要重新改数据,只需要在写入时改用utf-8-sig编码:
with open("orders.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) ...utf-8-sig会在文件开头写入一个BOM(Byte Order Mark,字节序标记),也就是\xef\xbb\xbf。Excel看到这个标记就知道这是UTF-8编码,于是老老实实按UTF-8来解码,中文显示正常。
这里要提醒一句:新编码规范不太推荐用BOM,但现实世界里Excel的地位太高,处理Windows用户的数据交换,utf-8-sig往往是唯一不挨骂的选择。如果你确定文件只在Linux或Python程序之间流转,继续用utf-8就好。
3.2 读取时的编码错误处理
读文件时最常遇到的是UnicodeDecodeError。文件是GBK编码,你用utf-8去读,遇到某个生僻字就炸了。解决办法是先试一个编码,不行就换:
import csv for encoding in ["utf-8-sig", "gbk", "gb18030", "latin-1"]: try: with open("orders.csv", encoding=encoding, newline="") as f: reader = csv.reader(f) first_rows = [next(reader) for _ in range(3)] print(f"成功:{encoding}") break except UnicodeDecodeError: continuegb18030比gbk覆盖的字符更多,遇到含特殊符号的旧文件时成功率更高。latin-1是最后的兜底方案,因为它对任何字节都能解码,代价是中文会变成乱码——至少程序不会崩。
3.3 不只是编码:换行符、科学计数法、ID前缀消失
编码之外,Excel和CSV的兼容性还有几个隐藏坑。
第一个坑是长数字,比如订单号12345678901234567890。Excel打开CSV后会自动显示成科学计数法,甚至丢失精度。这不是CSV文件本身的问题,而是CSV里没有类型定义,Excel默认把长得像数字的值都按数值处理。解决办法是在导出时给这种字段加上不可见前缀,比如在值前面加一个制表符\t或者用=连接的公式形式,但这会污染数据。
更干净的思路是:如果接收方明确用Excel看文件,这类字段可以全部转成字符串后在前面加一个单引号,Excel会认为它是文本而不是数值,虽然界面上会看到一个小标记,但至少数字不丢精度。
第二个坑是日期格式。同一个CSV文件,Excel可能把2024-01-01显示成01/01/2024,而Python读出来还是2024-01-01。这类格式差异不会损坏数据,但会给肉眼核验带来很大困扰。我的习惯是在生成CSV时统一给固定日期格式,并在文档里标明。
4. 让字段拥有名字:DictReader与DictWriter
csv.reader返回的是每行列表,位置靠索引访问。字段少还好,字段一多,代码里全是row[3]、row[7],过两周自己都看不懂。这时候改用DictReader和DictWriter会舒服得多。
4.1 为什么要用字典模式
DictReader会把首行当作字段名,后面每一行都变成字典:
import csv with open("orders.csv", encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: print(row["customer_name"], row["amount"])输出:
张三 299.0 李四 59.9代码阅读性瞬间提升,不再需要记忆第几个字段是什么含义。而且字段缺失时,字典里对应键的值为None,比列表越界更容易处理。
DictWriter反过来,写入时不需要按位置对齐,它根据字段名从字典里取值:
import csv fieldnames = ["order_id", "customer_name", "amount", "note"] with open("orders_out.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerow({ "order_id": 1001, "customer_name": "张三", "amount": 299.00, "note": "已发货, 备注加急", })4.2 DictReader的字段名和缺失值处理
如果原CSV首行没有表头,你也可以手动指定字段名:
fieldnames = ["order_id", "customer_name"] with open("no_header.csv", encoding="utf-8", newline="") as f: reader = csv.DictReader(f, fieldnames=fieldnames) for row in reader: print(row["order_id"])这时第一行会被当成数据而不是表头。
遇到某行字段比表头少,DictReader不会报错,缺失字段的值就是None。但要注意,如果某行的字段数更多,多余部分会被放到None键下面,这时你可能需要检查数据本身是否有问题。
4.3 DictWriter的字段顺序与额外字段控制
DictWriter最让人意外的行为是:如果你传入的字典里出现了fieldnames之外的键,默认会抛出ValueError,因为它不知道这个字段应该写到哪一列。想跳过这些额外字段,可以设置extrasaction="ignore":
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")这个参数我建议永远显式写上。数据源里多一个字段,程序直接崩溃还是悄悄忽略,取决于你的场景。写日志类数据时宁可直接报错,好让我尽早发现问题;写最终报表时则希望多字段自动去掉,不阻塞主流程。
5. 数据量上来之后:用pandas操作CSV
处理几百行CSV,标准库完全够用。但如果文件是几万行、几十万行甚至上百万行,再用csv.reader循环逐行处理,速度慢不说,后面做筛选、分组还得自己造轮子。这种情况就该上pandas了。
5.1 安装和导入pandas
pip install pandas导入时的习惯写法:
import pandas as pd5.2 read_csv的参数远比你想的多
读CSV是pandas最常用的操作之一,但很多初学只看pd.read_csv("data.csv")。实际上,真实场景里用得上的参数特别多:
df = pd.read_csv( "data.csv", encoding="utf-8-sig", delimiter=",", header=0, # 第一行是表头 dtype={"order_id": str}, # 防止长数字被当数值而丢失精度 parse_dates=["date"], # 自动解析日期列 usecols=["order_id", "amount", "date"], nrows=1000, # 只读前1000行,快速瞄一眼 )其中dtype这个参数非常值得强调。订单号、身份证号这类超长数字列,如果你不指定dtype={"order_id": str},pandas默认会解析成int64。一旦数字超过int64上限,或者带前导零,精度就丢了。我在银行数据项目中就遇过:交易流水号读取后结尾变成了0,排查了半天才发现是数据类型问题。所以所有语义上是“编号”而不是“数值”的列,强烈建议读进来时全转成字符串。
5.3 to_csv的参数细节
pandas写CSV同样有一堆细节。最常用的组合我直接列出来:
df.to_csv( "output.csv", index=False, # 不要写出行索引 encoding="utf-8-sig", # 保证Excel打开不乱码 sep=",", # 分隔符 lineterminator="\n", # 行结束符,Linux上干净 columns=["col1", "col2"], # 指定输出列顺序 date_format="%Y-%m-%d %H:%M:%S", )最坑的就是index。默认情况下to_csv会把DataFrame的行索引作为第一列写进文件,生成带Unnamed: 0列的文件。很多人第一次用pandas导出CSV后打开发现多了一列,就是忘记加index=False。
5.4 内存不够时:分块读取与chunksize
如果文件大到内存放不下,比如几个GB的CSV,可以用chunksize分块读取:
chunk_iter = pd.read_csv("big_data.csv", chunksize=50000, encoding="utf-8-sig") total_sales = 0 for chunk in chunk_iter: sales = chunk["amount"].sum() total_sales += sales # 也可以在这里做逐块清洗或写入数据库read_csv返回的不是DataFrame而是TextFileReader迭代器,每次迭代返回50,000行的DataFrame。这样内存占用就控制在一个很小的范围,数据再多也能按批处理。
还有一种更省内存的方式是只选择需要的列,隔行扫描等,但这属于更高阶的优化,第23天能掌握分块读取已经能应付大多数场景。
6. 实战环节:从一堆CSV中整理出干净的客户名单
理论说了不少,来一个完整的例子。假设你手上有多个月份的订单CSV,文件名分别是orders_2024_01.csv、orders_2024_02.csv,字段有order_id、customer_name、email、amount、order_date。现在要汇总所有不重复的客户邮箱,并统计每个客户的累计消费金额,最后输出到一个新的CSV。
6.1 需求拆解
汇总多文件数据有两个思路:一是用标准库逐行处理,逻辑透明,不依赖pandas;二是用pandas把多个文件读进来合并,再groupby聚合。两种都写一下,顺便对比。
6.2 用标准库实现的版本
import csv import glob customer_total = {} orders_count = 0 for filename in glob.glob("orders_2024_*.csv"): with open(filename, encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for row in reader: orders_count += 1 email = row["email"].strip().lower() amount = float(row["amount"]) customer_total[email] = customer_total.get(email, 0) + amount with open("customer_summary.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["email", "total_amount", "order_count"]) for email, total in sorted(customer_total.items(), key=lambda x: x[1], reverse=True): writer.writerow([email, round(total, 2)])这里有一个值得注意的点:email = row["email"].strip().lower()。数据清洗时我会对邮箱做去空格和转小写,防止同一个客户在不同月份录入时大小写不一致导致统计错误。这种细节在处理真实数据时特别常见,别忽略。
6.3 用pandas实现的版本
pandas版本更短:
import pandas as pd import glob files = glob.glob("orders_2024_*.csv") df_list = [pd.read_csv(f, encoding="utf-8-sig", dtype={"order_id": str}) for f in files] df = pd.concat(df_list, ignore_index=True) df["email"] = df["email"].str.strip().str.lower() df["amount"] = pd.to_numeric(df["amount"], errors="coerce") summary = df.groupby("email")["amount"].agg(total_amount="sum", order_count="count") summary = summary.sort_values("total_amount", ascending=False).reset_index() summary.to_csv("customer_summary_pandas.csv", index=False, encoding="utf-8-sig")pd.to_numeric配合errors="coerce"可以把无法转换成数字的值变成NaN,而不是直接让程序崩掉。真实数据里总会有脏数据,这句写法是处理脏数据的标准姿势。
6.4 两种做法的差异和一个测试技巧
标准库版本开销小、无需额外依赖,适合脚本运行环境受限的场合;pandas版本代码更简洁、扩展性强,适合后续还要继续做复杂分析的场景。
执行完后,可以用pandas快速验证汇总结果:
check = pd.read_csv("customer_summary_pandas.csv") print(check.head()) print(check["email"].nunique())这样至少能确认输出文件不是空的,客户数量和预期一致。
7. 这一年做CSV处理积攒下来的避坑清单
最后这部分是真正的经验沉淀,全是代码文档不会告诉你的零碎细节。
7.1 字段里的逗号和引号
只要数据里有带逗号的字段,手动用split(",")切分就不安全,写CSV时也一定要让csv模块来处理转义。反过来说,你从别人手里拿到CSV时,最好先检查有没有引号和转义问题,办法是用csv.reader读一次,不要用文本编辑器肉眼判断。
7.2 空行和行尾空格
从Windows生成的CSV读入Python后,有时会发现行尾有\r,好在csv模块默认会用\r\n处理。比较讨厌的是某些工具导出的CSV每行末尾有杂散空格,字段值里也带着空格,导致去重失败。我的习惯是读取时对关键列统一做strip(),而不是在每个打印里手动处理。
7.3 UTF-8编码下的中文路径
在Windows上,如果你把CSV放在中文目录里,然后直接用open()读取,偶尔会遇到文件不存在或权限错误。这通常和系统编码有关。稳妥的做法是在程序前部声明:
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")或者干脆在读取时用pathlib.Path对象,让它来处理路径规范化:
from pathlib import Path path = Path("C:/项目数据/订单/orders.csv") with path.open("r", encoding="utf-8-sig", newline="") as f: ...7.4 大文件性能优化经验
如果你已经用了pandas的chunksize还是觉得慢,有几个方向可以排查。第一个是列太多,很多列根本用不上,usecols可以大幅减少内存和IO时间。第二个是CSV里的字段带大量引号,解析成本偏高,条件允许的话让上游导出成无引号版本。第三个是不要随便在DataFrame上做双重循环,能向量化操作就向量化。
另外一个压箱底的经验:如果你想校验CSV文件有没有被篡改,可以用hashlib.md5()读取整个文件后计算MD5值,但这只适合小文件,几GB的文件计算MD5会消耗很长时间。更实用的场景是,在做数据传输时,同时发送CSV文件和对应的校验值,接收方拿到后重新算一次,如果一致说明文件没损坏,这只是个简单的额外安全措施,不要指望它防篡改,因为它不防“两个文件都没被改但其中一个是别人的”。
7.5 关于编码的一个最终建议
这几年我处理过的CSV少说也有上千份,最大的感受是:编码问题一定要在项目初期就定好规范。如果你的团队内部统一用UTF-8,那么生成方导出时就用utf-8-sig,读取方导入时也统一按utf-8-sig解码。如果以后还会和其他平台对接,最好在文档里写明生成时的编码、分隔符、日期格式。很多“玄学乱码问题”,本质上就是双方没有提前对齐这些最基本的信息。
我在实际项目里一般会在CSV文件的同目录放一个README.txt,里面写清楚文件编码、字段含义、生成日期和注意事项。听起来很啰嗦,但每次有人来问“这个文件为什么打不开”的时候,我都会把它当作救命文档。
CSV读写这件事,表面上看只是几十行代码的事,实际上理解清楚之后,你能避开的坑远超想象。标准库csv模块和数据科学里的pandas就是你的左右手,一个轻量、一个强大。选择哪个取决于你的数据量、依赖环境和后续打算。前面说的那些参数,我建议你不只是看,最好都动手试一遍:故意造一个带逗号、带引号、带中文的文件,然后用不同编码读写,亲眼看看变化,印象会深得多。等你真正处理过各种奇形怪状的CSV文件,再回头看这一天的内容,会发现这些基础操作其实是后续所有数据处理工作的起点。