春江暮客

春江暮客的个人学习分享网站

用 Python 流式把 CSV 转成 JSONL:保留前导零、多行字段与失败保护

2026-10-25 技术
用 Python 流式把 CSV 转成 JSONL:保留前导零、多行字段与失败保护

定时任务拿到 CSV,下游工具却要求每行一个 JSON 对象。直接按逗号切分会破坏带引号的字段,一次读入整个文件又增加内存占用。本文用 Python 标准库逐条转换,保留中文、前导零和字段内换行,并在全部检查成功后才创建最终文件。

面向初级到中级读者,示例适用于 Linux 上已经写完、转换期间不会变化的本地 CSV。无需第三方包。

1. 方法与数据约定

  1. 输入是逗号分隔的 UTF-8 CSV,可以带开头 BOM;第一条记录是表头。列名不能为空或重复,保留原始空格并按原文比较。
  2. 所有值都保留为字符串:001、true、12.50 不自动转型,空字段变成 ""。列数必须一致,空白记录也会失败。
  3. 每条数据记录写成一个 JSON 对象,加一个 LF。字段内的换行由 JSON 编码器转义,不会拆成两条 JSONL 记录。
  4. 空文件失败;只有表头的 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. 验证内容与失败行为

  1. 验证实际文件,不只看成功提示:
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
  1. 在新的实验目录中继续运行下面检查;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 编码器为每条记录写一行,能保留字符串和多行字段。把输出留在暂存路径,全部成功后才创建最终名称,可以让下游通过退出码和明确的文件边界判断是否继续处理。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configuration copy cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo imports indexnow javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tensorflow tkinter tls toml tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它