AI 技术博客
返回首页
Python 基础 27 分钟阅读

结构化文件:CSV 与 JSON 的读写

PythonCSVJSON文件读写

引言

第七章《文件与异常处理》走到第七篇。前六篇的推进顺序是:第 1-3 篇讲完了 open 的本质(文件对象、模式与关闭)、读写 API 全解(read/readline/迭代与 writelines)、编码参数(encoding 与文本/二进制模式);第 4 篇的 pathlib 换了一种操作路径的方式;第 5 篇用异常体系做了防御式读写;第 6 篇的 os 模块解决了目录与系统交互。但这些篇目处理的都是”没有内部结构的纯文本”。真实工程里数据几乎总是有形状的:要么是表格(行与列的二维结构),要么是树(键与值的嵌套结构)。两种最普及的结构化文本格式恰好对应这两种形状——CSV 是表格的文本化,JSON 是树的文本化。

本篇要做的事:把表格数据读出来(csv.reader/DictReader)、写出去(csv.writer/DictWriter),把树形数据序列化与反序列化(json.dumps/loads/dump/load),并讲透这两种格式各自的经典坑:写 CSV 必须 newline=” 的原因、中文 JSON 的 ensure_ascii 参数、损坏 JSON 怎么报错。

前置知识:open 与 with、encoding 参数来自本篇第 1-3 篇;字典与列表来自第六章;异常捕获与自定义异常来自第四章第 8 篇(本篇的 JSONDecodeError 处理就是它的直接应用)。下一篇(第 8 篇)将把本文的 JSON 读写技能用于配置文件实战,第 9 篇的日志监控工具也会用到本文的格式基础,第 10 篇测验则统一考察本章内容。

概念与原理

CSV:展平的二维表

CSV 的全称是 Comma-Separated Values(逗号分隔值)。它把一张表”展平”成纯文本:一行是一条记录(record),记录内部的字段(field)用分隔符隔开,默认分隔符是逗号。CSV 的起源远早于 Python——电子表格与主机记账程序时代就有了这种”用分隔符组织表格”的想法,IETF 在 2005 年用 RFC 4180 为它正式规定了 text/csv 媒体类型和引用规则。

CSV 的引用规则只有两条:其一,当字段本身包含分隔符、双引号或换行符时,整个字段用双引号包裹起来;其二,字段内部的双引号写作两个连续的双引号("")作为转义。为什么不能直接用字符串的 split(’,’) 自己切分?因为被双引号包裹的字段内部可能就含有逗号——“他说:“你好,世界"" 这个字段里那个逗号并不是分隔符。csv 模块的解析器是一个逐字符扫描的小状态机:它在”引号外”和”引号内”两种状态之间切换,只有引号外的逗号才作为分隔符,所以文档里写得再乱也不会切错。

另一个重要事实:CSV 没有类型系统。读者读到的每个单元格都是字符串,数字 “25”、日期 “2026-09-02” 一律是 str,需要调用方自己用 int()/float() 转换。这是 CSV 与 JSON 最本质的差异之一。

为什么写 CSV 必须 newline=”

这是 csv 模块最经典的坑,值得从原理上理解。文件对象以文本模式 open 时,默认参数 newline=None 会让文件对象做”换行符翻译”:写的时候把 \n 翻译成操作系统默认的行尾(Windows 上是 \r\n),读的时候把 \r\n 翻译回 \n。而 csv 模块自己管理行尾——excel 方言规定记录之间用 \r\n 结尾,csv.writer 每写完一行就自己输出一次 \r\n。两者叠加的后果在 Windows 上非常明显:csv 输出的 \r\n 被文件对象再次翻译,\n 又变成了 \r\n,于是磁盘上出现 \r\r\n,也就是”每条记录后面多出一个空行”。反过来,csv.reader 读这种文件时会看到一堆空行。给 open 传 newline=” 的意思是”别替我翻译,行尾由 csv 模块全权负责”,这是文档明确要求的写法。下面的实测对比会给出真实的字节序列。

JSON:带类型的树

JSON(JavaScript Object Notation)由 RFC 8259(2017 年 12 月)定义,源自 JavaScript 的对象字面量语法,但已经成为语言无关的通用数据格式:几乎所有语言的生态里都有 JSON 的读写库,Python 的 json 模块就是标准库,无需安装。

JSON 的语法出奇地小,只有六种值:object(花括号包着的”键:值”对,键必须是字符串)、array(方括号里的有序列表)、string、number、true/false 和 null。因为 object 和 array 可以互相嵌套(数组里放对象、对象里放数组、无限递归),JSON 能表达任意深度的树形结构,这正是它和 CSV 的分水岭:CSV 只能表达扁平的二维表,JSON 可以表达”用户对象里嵌着一堆订单对象”这种文档型数据。另一个分水岭是类型:JSON 里的 number 解析成 Python 的 int 或 float,true 解析成 True,null 解析成 None——类型语义是格式自带的,不像 CSV 全靠字符串。

ensure_ascii、indent 与 sort_keys 为什么存在

json.dumps 默认参数的取舍背后有一段历史:Python 2 时代字符串是字节序列,编码错误在交换数据时非常常见,所以序列化器默认 ensure_ascii=True,把所有非 ASCII 字符转成 \uXXXX 形式的转义序列,保证输出是纯 ASCII 字节,在任何环境、任何编码下都不会出错。代价显而易见:中文变成了一串 \u5f20\u4e09 这样的乱码文本。今天的文件、数据库、网络传输早已全面 UTF-8 化,所以处理中文 JSON 时主流做法是显式 ensure_ascii=False,配合打开文件时 encoding=“utf-8”,让磁盘上直接是可读的中文。

indent 和 sort_keys 则是工程习惯的产物:indent=2 让嵌套结构分层缩进,肉眼可读、便于在 git 里做 diff;sort_keys=True 让输出键的顺序字典序固定,同一份数据反复序列化得到逐字节一致的文本,这对”生成文件后比对是否变化”这类场景非常有用。本篇的演示会在同一份中文数据上把这两个参数的差别真实打印出来。

JSON 与 Python 的类型映射

双向映射是写 JSON 代码必须烂熟于心的基础,下面用一张表总结(均已在本机 Python 3.12 实测):

Python 对象序列化后的 JSON 写法再解析回来的 Python 类型
dictobjectdict
listarraylist
tuplearray(元组也写成数组)list
strstringstr
intnumberint
floatnumberfloat
booltrue / falsebool
NonenullNone

注意两个不对称:第一,Python 的 tuple 序列化后是数组,解析回来变成 list,元组语义不保留;第二,解析 JSON 数字时没有小数点的解析成 int,带小数点的解析成 float,而且 JSON 里的 true/false/null 在 Python 里是首字母大写的 True/False/None。

场景取舍:CSV 还是 JSON

选型的判断标准可以浓缩成三条。第一,形状:列固定的二维表用 CSV,嵌套的树形文档用 JSON。第二,类型:需要保留数字、布尔语义或用程序直接交换(比如 Web API)时用 JSON;只是给人看、给 Excel 用的报表用 CSV。第三,规模:csv.reader 是逐行流式的,可以处理几 GB 的表格;而 json.load 会把整个文档一次性读进内存再解析,超大 JSON 需要分块设计。CSV 体积小、Excel 双击就能打开;JSON 生态大、类型与嵌套天然。没有谁绝对更好,只有当前场景更合适。

操作与实现

csv.reader:逐行解析

csv.reader 接受任何”可迭代的行序列”(文件对象、StringIO、字符串列表都行),逐行吐出一个字段列表。先看最小示例:

import csv
import io

data = io.StringIO("name,age,city\nAlice,25,Beijing\nBob,30,Shanghai\n")
reader = csv.reader(data)
for row in reader:
    print(row)
['name', 'age', 'city']
['Alice', '25', 'Beijing']
['Bob', '30', 'Shanghai']

注意第一行表头也在输出里,需要的话自己跳过;“25” 是字符串而不是数字,这正是前面说的数组字段全字符串问题。

newline=” 的真实字节对比

下面用同一份数据、两种打开方式写出两个文件,直接看磁盘上的原始字节,再读回 bad.csv 看后果:

import csv
import os
import tempfile

tmp = tempfile.mkdtemp()

good_path = os.path.join(tmp, "good.csv")
with open(good_path, "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["name", "age"])
    writer.writerow(["Alice", 25])

bad_path = os.path.join(tmp, "bad.csv")
with open(bad_path, "w", encoding="utf-8") as f:   # 忘了 newline=""
    writer = csv.writer(f)
    writer.writerow(["name", "age"])
    writer.writerow(["Alice", 25])

with open(good_path, "rb") as f:
    print("newline='':  ", repr(f.read()))
with open(bad_path, "rb") as f:
    print("缺省 newline:", repr(f.read()))

print("--- 读回 bad.csv 得到 ---")
with open(bad_path, encoding="utf-8") as f:
    for row in csv.reader(f):
        print(repr(row))
newline='':   b'name,age\r\nAlice,25\r\n'
缺省 newline: b'name,age\r\r\nAlice,25\r\r\n'
--- 读回 bad.csv 得到 ---
['name', 'age']
[]
['Alice', '25']
[]

字节对比一目了然:正确写法每条记录以一个 \r\n 结尾;错误写法变成 \r\r\n(record 之间被塞进了空行),读回时 csv.reader 每两条真实记录之间都多出一个空行 []。在 Excel 里打开错误文件同样会看到行间空行。这就是 csv 官方文档要求 newline=” 的完整理由。

writer 与 writerows:写出整个表格

writerows 接受一个”行的列表”,配合表头一次性写出。注意数值 25000 写进文件后会变成文本 “25000”,这是 CSV 的应用规则:

import csv
import os
import tempfile

tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "users.csv")

with open(path, "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["name", "dept", "salary"])
    writer.writerows([
        ["Alice", "研发", 25000],
        ["Bob", "运营", 18000],
        ["Carol", "市场", 16000],
    ])

with open(path, encoding="utf-8") as f:
    rows = list(csv.reader(f))

for row in rows:
    print(row)
print("总记录数(不含表头):", len(rows) - 1)
['name', 'dept', 'salary']
['Alice', '研发', '25000']
['Bob', '运营', '18000']
['Carol', '市场', '16000']
总记录数(不含表头): 3

DictReader:用表头当键

当文件带表头时,DictReader 用第一行作为字段名,把后续每一行包装成字典,row[“name”] 这种写法可读性高得多,代码里也不再依赖”第几列”这种脆弱的索引:

import csv
import io

data = io.StringIO("""name,dept,salary
Alice,研发,25000
Bob,运营,18000
""")

reader = csv.DictReader(data)
for row in reader:
    print(row["name"], row["dept"], int(row["salary"]) * 12)
Alice 研发 300000
Bob 运营 216000

DictWriter:按字段名写回

DictWriter 是 DictReader 的镜像:构造时必须传 fieldnames(字段名列表),writeheader() 写出表头行,writerows 接受字典列表。字段顺序由 fieldnames 决定,与字典本身的顺序无关:

import csv
import os
import tempfile

tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "out.csv")
fieldnames = ["name", "dept", "salary"]

with open(path, "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows([
        {"name": "Dave", "dept": "研发", "salary": 21000},
        {"name": "Eve", "dept": "运营", "salary": 19000},
    ])

with open(path, encoding="utf-8") as f:
    print(f.read(), end="")

try:
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerow({"name": "X", "dept": "Y", "salary": 1, "extra": "多余"})
except ValueError as e:
    print("多余字段报错:", e)
name,dept,salary
Dave,研发,21000
Eve,运营,19000
多余字段报错: dict contains fields not in fieldnames: 'extra'

第二个 with 块说明 DictWriter 的默认行为 extrasaction=‘raise’:字典里出现 fieldnames 之外的多余键,直接抛 ValueError,防止静默丢字段。

引号规则实测

前面原理部分说的引用规则,由 csv 模块自动完成。下面的字段本身含逗号和双引号,writer 自动用双引号包裹并把内部引号翻倍,reader 读回时还原得一字不差:

import csv
import io

buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(["摘录", '他说:"你好,世界"', 42])

print(repr(buf.getvalue()))
buf.seek(0)
for row in csv.reader(buf):
    print(row)
'摘录,"他说:""你好,世界""",42\n'
['摘录', '他说:"你好,世界"', '42']

分隔符与方言

欧洲不少系统的 CSV 用分号(Excel 的法语/德语本地化就用分号),这时传 delimiter=”;” 即可。如果同样的参数组合要在多处复用,可以注册一个命名方言(dialect),它本质上就是一组参数——分隔符、引用符、行尾、模板的打包:

import csv
import io

text = "name;age;city\nAlice;25;Beijing\n"
for row in csv.reader(io.StringIO(text), delimiter=";"):
    print(row)

csv.register_dialect("semicolon", delimiter=";", skipinitialspace=True)
rows = list(csv.reader(["a; b; c", "d;e"], dialect="semicolon"))
print("自定义方言:", rows)
['name', 'age', 'city']
['Alice', '25', 'Beijing']
自定义方言: [['a', 'b', 'c'], ['d', 'e']]

skipinitialspace=True 表示”字段开头的空格不算内容”,真实数据里”a; b”这种写法很常见;同样的参数打包进方言后,每一处 csv.reader 只需传 dialect=“semicolon”。

json.dumps:把 dict 序列化成字符串

来看中文数据的真实输出。同一份数据,默认参数下中文全部变成 \uXXXX 转义序列;ensure_ascii=False + sort_keys=True 后输出既保留中文,键序又稳定:

import json

data = {
    "name": "张三",
    "city": "北京",
    "age": 28,
    "skills": ["Python", "数据分析"],
    "score": 92.5,
}

print("默认参数输出:")
print(json.dumps(data, indent=2))
print("ensure_ascii=False + sort_keys=True:")
print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True))
默认参数输出:
{
  "name": "\u5f20\u4e09",
  "city": "\u5317\u4eac",
  "age": 28,
  "skills": [
    "Python",
    "\u6570\u636e\u5206\u6790"
  ],
  "score": 92.5
}
ensure_ascii=False + sort_keys=True:
{
  "age": 28,
  "city": "北京",
  "name": "张三",
  "score": 92.5,
  "skills": [
    "Python",
    "数据分析"
  ]
}

注意两点:\u5f20\u4e09 就是”张三”的 Unicode 码点转义,它并不是乱码,放进任何 JSON 解析器都能正确还原;而 sort_keys=True 让键按字典序排列(age、city、name、score、skills),输出完全确定。

json.loads:解析字符串并还原类型

loads 的方向反过来,同时展示了类型映射表在真实数据上的表现:

import json

text = '{"name": "张三", "age": 28, "score": 92.5, "ok": true, "hobby": null, "tags": ["a", "b"]}'
obj = json.loads(text)
print(obj)
for key, value in obj.items():
    print(" ", key, "->", type(value).__name__)

print(json.loads("42"), type(json.loads("42")).__name__)
print(json.loads("3.14"), type(json.loads("3.14")).__name__)
{'name': '张三', 'age': 28, 'score': 92.5, 'ok': True, 'hobby': None, 'tags': ['a', 'b']}
  name -> str
  age -> int
  score -> float
  ok -> bool
  hobby -> NoneType
  tags -> list
42 int
3.14 float

与文件对象配合:json.dump / json.load

序列化到文件用的是 dump 和 load(没有 s 后缀的版本),它们直接接受文件对象。注意返回值:dump 返回 None,数据写进文件;load 返回解析出的完整对象。文件必须用 encoding=“utf-8” 打开,否则写中文会按 Windows 默认编码处理,跨平台就乱码:

import json
import os
import tempfile

tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "notes.json")
data = {"title": "周末计划", "items": ["买菜", "跑步", "写博客"]}

with open(path, "w", encoding="utf-8") as f:
    json.dump(data, f, ensure_ascii=False, indent=2)

with open(path, encoding="utf-8") as f:
    print(f.read())

with open(path, encoding="utf-8") as f:
    loaded = json.load(f)

print("读回相等:", loaded == data)
{
  "title": "周末计划",
  "items": [
    "买菜",
    "跑步",
    "写博客"
  ]
}
读回相等: True

读取损坏 JSON:JSONDecodeError 的应用

外部来的 JSON(文件、API 响应、用户上传)可能损坏。json 模块抛出的 JSONDecodeError 是第四章讲过的 ValueError 的子类,它额外携带出错位置信息:msg 是错误原因,lineno/colno 是行号列号,pos 是出错处的字符下标,doc 是完整原文。下面这份文本在 “age”: 后面丢了数字,捕获后精确报告位置:

import json

broken = '{"name": "张三", "age": }'
try:
    json.loads(broken)
except json.JSONDecodeError as e:
    print("错误原因:", e.msg)
    print("所在位置: 第 %d 行第 %d 列" % (e.lineno, e.colno))
    print("出错片段:", repr(broken[e.pos - 8:e.pos + 8]))

print("JSONDecodeError 是 ValueError 的子类:", issubclass(json.JSONDecodeError, ValueError))
错误原因: Expecting value
所在位置: 第 1 行第 23 列
出错片段: ' "age": }'
JSONDecodeError 是 ValueError 的子类: True

因为 JSONDecodeError 继承自 ValueError,只想”粗粒度”区分格式错误时可以用 except ValueError,想拿到精确位置就捕获 JSONDecodeError 本身;如果要进一步转成业务自定义异常,可以用第四章讲过的 raise … from e 保留异常链。

易错点与陷阱

本节内容全部来自上面实测过的真实行为,值得反复对照。

  1. 写 CSV 忘带 newline=”。后果是磁盘上出现 \r\r\n,文件里每条记录之间多一空行,读回时 reader 会吐出空行 []。这篇的字节对比是最直接的证据。判断口诀:文件对象的换行翻译与 csv 模块自己的行尾管理是两套机制,csv 场景下文件对象必须交出换行管理权。

  2. 中文 JSON 的 ensure_ascii 与编码双坑。忘写 ensure_ascii=False,中文变成 \uXXXX 转义序列,人不可读;文件打开忘写 encoding=“utf-8”,在 Windows 上会按区域默认编码(如 GBK 家族)读写,同一份代码换台机器、换台服务器结果就不同。规范写法:json.dump(…, ensure_ascii=False) 配 open(path, “w”, encoding=“utf-8”)。

  3. DictWriter 的字段集合不一致。字典里出现 fieldnames 之外的键,默认直接抛 ValueError: dict contains fields not in fieldnames(前面已实测);反过来,字典缺少某个键时会写出空字符串(restval 默认值),常常造成”数据悄悄变空”。写入前用 set 一致性检查值得养成习惯。

  4. 序列化的类型边界。tuple 序列化成数组、解析回来变成 list;set、bytes、datetime 等类型直接抛 TypeError: Object of type set is not JSON serializable。JSON 没有日期类型,日期要自行约定格式(如 ISO 8601 字符串 “2026-09-02”),这是团队协作时必须写进约定文档的细节。

  5. CSV 读出来全是字符串。数字 “25”、百分比 “5.0%” 都是 str,参与计算前必须 int()/float() 转换;另外 Excel 保存的中文 CSV 常带 BOM(文件开头三个字节 \xef\xbb\xbf),用 encoding=“utf-8” 读会把 \ufeff 粘在第一个表头字段前,改用 encoding=“utf-8-sig” 即可自动剥离。

小结

CSV 与 JSON 是两种互补的结构化文本格式:CSV 展平表格、没有类型、可流式处理、Excel 友好,用 csv 模块读写时切记 open(…, newline=”) 与字段全字符串这两个性质;JSON 表达嵌套树、类型自带、生态最大,用 json 模块时记得中文场景的 ensure_ascii=False + encoding=“utf-8” 组合,以及 indent/sort_keys 对可读性与稳定输出的作用。两者的读写 API 都是”reader/writer(带 s 的在内存里倒腾,不带 s 的配合文件对象)“这一对称结构。损坏的 JSON 用 JSONDecodeError 捕获并报告行列位置。这些技能正是下一篇配置读取实战的原料。

练习与思考题

  1. 写一个函数 build_csv(rows, path):用 csv.writer 把行列表写入 path(记得 newline=” 与 encoding=‘utf-8’),再用 csv.reader 读回并断言(assert)读回的行与写入的行完全一致。注意:写入的数值会被转成字符串,断言前要把行统一转成字符串再比较。
  2. 把下面这棵数据用 json.dump 写入文件,要求磁盘上是可读中文、键序稳定、缩进两层,然后 json.load 读回并打印 items 的数量:{“user”: {“name”: “李四”}, “items”: [{“id”: 1, “done”: True}, {“id”: 2, “done”: False}]}。提示:encode 参数组合是 ensure_ascii=False + sort_keys=True + indent=2。
  3. 写一段捕获代码:手工造一份损坏的 JSON(比如把布尔写成 True),用 json.loads 解析并捕获 JSONDecodeError,打印其中的 msg 与 colno,说明为什么 “True” 在 JSON 里是非法的。提示:JSON 布尔必须小写 true,出错列号正好落在大写字母处。
  4. 思考题:一份带 200 万行的访问日志导出的统计表,和一份 Web API 返回的嵌套用户详情,分别应该选 CSV 还是 JSON?给出你的三条判断依据(形状、类型、消费方/规模),并说明这两种格式各自的劣势在哪里。