用 Python 流式把 CSV 转成 JSONL:保留前导零、多行字段与失败保护
定时任务拿到 CSV,下游工具却要求每行一个 JSON 对象。直接按逗号切分会破坏带引号的字段,一次读入整个文件又增加内存占用。本文用 Python 标准库逐条转换,保留中文、前导零和字段内换行,并在全部检查成功后才创建最终文件。
面向初级到中级读者,示例适用于 Linux 上已经写完、转换期间不会变化的本地 CSV。无需第三方包。
1. 方法与数据约定
- 输入是逗号分隔的 UTF-8 CSV,可以带开头 BOM;第一条记录是表头。列名不能为空或重复,保留原始空格并按原文比较。
- 所有值都保留为字符串:
001、true、12.50不自动转型,空字段变成""。列数必须一致,空白记录也会失败。 - 每条数据记录写成一个 JSON 对象,加一个 LF。字段内的换行由 JSON 编码器转义,不会拆成两条 JSONL 记录。
- 空文件失败;只有表头的 CSV 成功生成 0 字节输出,表示零条记录。下游若要求至少一条数据,应另行拒绝空结果。
JSON Lines 格式说明要求 UTF-8、每行一个合法 JSON 值且不能带 BOM。本文选择每行一个对象,便于批处理;JSONL 本身也允许其他 JSON 值。
如果还要固定业务列名及顺序,可先接上CSV 结构校验教程。本工具只检查通用结构,不推断类型或业务规则。
2. 准备环境与示例数据
使用已有的 Python 3.10 或更新版本。本文在 Linux、Python 3.11.4 下测试。选择尚不存在的目录,后续命令在同一目录运行:
python3 --version
mkdir csv-jsonl-demo
cd csv-jsonl-demo
cat > samples.csv <<'CSV'
sample_id,note
001,中文结果
002,"alpha, beta"
003,"first line
second line"
004,
CSV
第三条数据跨两个物理行,但仍是一条 CSV 记录。把下一节保存为 csv_to_jsonl.py,或下载完整脚本。
3. 完整实现
"""Stream UTF-8 CSV to JSONL without overwriting output. Python 3.10+, Linux."""
import argparse
import csv
import json
import os
from pathlib import Path
import sys
import tempfile
def convert(source, output):
with source.open(encoding="utf-8-sig", newline="") as incoming:
reader = csv.reader(incoming, strict=True)
header = next(reader, None)
if not header or any(not name.strip() for name in header):
raise ValueError("missing or empty column name")
if len(set(header)) != len(header):
raise ValueError("duplicate column name")
count = 0
with tempfile.TemporaryDirectory(prefix=".csv-jsonl-", dir=output.parent) as work:
staged = Path(work) / "result.jsonl"
with staged.open("x", encoding="utf-8", newline="\n") as outgoing:
for number, row in enumerate(reader, 1):
if len(row) != len(header):
raise ValueError(
f"record {number}, ending at CSV line {reader.line_num}: "
f"expected {len(header)} fields, got {len(row)}"
)
record = dict(zip(header, row))
outgoing.write(json.dumps(record, ensure_ascii=False) + "\n")
count += 1
os.link(staged, output)
return count
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
try:
count = convert(args.source, args.output)
except (OSError, UnicodeError, csv.Error, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"OK: {count} records -> {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Python CSV 官方文档说明了 newline="" 的用法以及默认字符串读取方式。这里使用 csv.reader,先检查重复表头和列数,再构造字典,避免重复字段被覆盖或多余字段被悄悄丢弃。strict=True 能发现部分解析错误,不能代替结构检查。
json.dumps() 官方文档说明了 ensure_ascii 参数。这里让中文保持可读,同时保留 JSON 对控制字符的转义;不要加 indent,否则一个对象会占多行。
输出采用两步提交:先在目标目录内暂存完整 JSONL,再用 os.link()创建最终名称。Linux 本地文件系统上已有的目标路径会让这一步失败,旧文件保持不变;暂存和最终文件位于同一文件系统。链接成功后,删除暂存名称不会删除最终文件的数据。
TemporaryDirectory 上下文管理器负责正常退出和异常展开时的清理。目标目录必须已存在、可写并支持硬链接;本例不承诺网络文件系统行为或断电持久性,也不使用 os.replace() 覆盖旧结果。强制终止可能留下 .csv-jsonl-* 暂存目录,清理前应先确认没有进程仍在使用它。
4. 运行与查看结果
python3 csv_to_jsonl.py --help
python3 csv_to_jsonl.py samples.csv samples.jsonl
cat samples.jsonl
成功提示是 OK: 4 records -> samples.jsonl,随后看到四行:
{"sample_id": "001", "note": "中文结果"}
{"sample_id": "002", "note": "alpha, beta"}
{"sample_id": "003", "note": "first line\nsecond line"}
{"sample_id": "004", "note": ""}
first line\nsecond line 在文件里是一行 JSON,解码后才恢复成字段里的真实换行。不要对整个 JSONL 使用一次 json.load();逐行调用 json.loads()。进一步检查可参考JSONL 校验教程。
内存主要随表头和最大单条记录变化,而不会随总记录数累计;CSV 解析器自身仍有字段长度限制。磁盘需要容纳完整的新输出,只有成功后才启动下游任务。需要了解逐条处理的基本方式,可参考大型 CSV 流式处理。
5. 验证内容与失败行为
- 验证实际文件,不只看成功提示:
python3 - <<'PY_CHECK'
import json
from pathlib import Path
payload = Path("samples.jsonl").read_bytes()
assert not payload.startswith(b"\xef\xbb\xbf")
assert payload.endswith(b"\n")
lines = payload.decode("utf-8").splitlines()
assert len(lines) == 4
rows = [json.loads(line) for line in lines]
assert rows[0] == {"sample_id": "001", "note": "中文结果"}
assert rows[1]["note"] == "alpha, beta"
assert rows[2]["note"] == "first line\nsecond line"
assert rows[3]["note"] == ""
print("OK: four objects, strings, UTF-8, and multiline field")
PY_CHECK
- 在新的实验目录中继续运行下面检查;
bad.csv的第一条数据合法,第二条故意多一列:
python3 - <<'PY_TEST'
from pathlib import Path
import subprocess
import sys
original = Path("samples.jsonl").read_bytes()
command = [sys.executable, "csv_to_jsonl.py"]
again = subprocess.run(command + ["samples.csv", "samples.jsonl"], capture_output=True)
assert again.returncode == 1
assert Path("samples.jsonl").read_bytes() == original
Path("bad.csv").write_text("a,b\n1,2\n3,4,5\n", encoding="utf-8")
bad = subprocess.run(command + ["bad.csv", "bad.jsonl"], capture_output=True, text=True)
assert bad.returncode == 1
assert not Path("bad.jsonl").exists()
assert not list(Path(".").glob(".csv-jsonl-*"))
print(bad.stderr.strip())
print("OK: existing output unchanged; late failure leaves no output")
PY_TEST
应包含 record 2, ending at CSV line 3: expected 2 fields, got 3,并输出检查成功提示。转换成功退出 0;输入、输出或校验失败退出 1;缺少参数等用法错误退出 2。测试还覆盖了 BOM、CRLF、空输入、只有表头、坏引号、无效 UTF-8、重复表头、已有符号链接目标和 10 万条记录。
6. 常见问题
| 错误或现象 | 处理方法 |
|---|---|
File exists |
换一个不存在的输出路径;不要把输出指定为输入本身。工具会先扫描输入,因此大文件可能到最后才报告冲突。 |
missing or empty column name / duplicate column name |
修正第一条 CSV 记录;不要用重复列名表达两个不同字段。 |
expected ... fields |
按错误里的记录号及结束物理行检查分隔符、引号和空白记录。 |
field larger than field limit |
确认字段合理,再按数据需要显式调整 csv.field_size_limit();不要用无限制读入掩盖异常。 |
| UTF-8 解码失败 | 先确认源编码并转换为 UTF-8;不要用 errors="ignore" 丢弃字节。 |
Operation not supported / Operation not permitted |
在已确认支持硬链接的本地目录输出;不要改成会覆盖文件的重命名操作。 |
No space left on device / Permission denied |
检查目标磁盘和目录权限,解决后用新输出名重跑。 |
总结
用 CSV 解析器按记录读取,再用 JSON 编码器为每条记录写一行,能保留字符串和多行字段。把输出留在暂存路径,全部成功后才创建最终名称,可以让下游通过退出码和明确的文件边界判断是否继续处理。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-csv-to-jsonl-stream.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。