用 Python 按数字编号合并 CSV:只留一个表头,失败时保留旧输出
拆开的 CSV 想重新合并,直接 cat 会重复表头;按文件名排序又可能把 part-10.csv 放到 part-2.csv 前面。字段里还有换行时,按文本行跳过表头也容易破坏记录。
本文面向初学者到中级读者,用 Python 标准库完成一个按文件名数字编号合并 CSV 的工具:先确定分片顺序,再逐条校验和写入;全部成功才替换最终文件。它是按记录拆分大型 CSV的后续,但也适合其他工具生成的同结构分片。
1. 方法:先明确排序和输入规则
不要把目录枚举顺序当成业务顺序。pathlib 官方文档说明,iterdir() 的返回顺序是任意的。即使显式按字符串排序,也会得到:
from pathlib import Path
print(sorted(path.name for path in Path("parts").glob("part-*.csv")))
# ['part-1.csv', 'part-10.csv', 'part-2.csv']
这里不做通用的“自然排序”,只接受一个明确格式:
- 读取目录顶层匹配
part-*.csv的条目,文件名必须严格为part-<正整数>.csv,只接受 ASCII 数字。part-old.csv、目录和符号链接报错,其他不匹配的文件忽略。 - 将编号转换为整数后排序;允许
part-0001.csv,但part-1.csv与part-01.csv同时存在时拒绝。编号可以有空缺,不会据此判断分片是否齐全。 - 所有文件是逗号分隔、双引号包裹字段的 UTF-8 CSV,可带 BOM。表头名称不能空白或重复,而且各文件的列名与顺序必须完全一致。
- 每条数据记录的字段数必须等于表头列数;空记录也报错。只有表头的分片合法,完全空文件不合法。
- 输出放在输入目录之外,父目录必须已存在,不能是符号链接。成功时会替换已有输出;校验或写入失败时,旧输出保留。
输入分片必须已经写完,合并期间不能有其他进程修改它们;输出也只允许一个写入者。接入上面的拆分工具时,应先确认其 _SUCCESS 标记存在。此程序不检查该标记,也不校验跨记录 ID 唯一性。
2. 准备:创建带特殊字段的分片
需要已有的 Python 3.9+,不用安装 pip 包;示例在 Linux、Python 3.11.4 下验证。以下 shell 命令适用于 Bash 或 zsh。
- 新建练习目录;重复练习时换个目录名,后续命令都在这个目录运行:
python3 --version
mkdir csv-merge-demo
cd csv-merge-demo
mkdir parts
python3 - <<'PY_DATA'
import csv
from pathlib import Path
for number, note in [(10, "last"), (1, "first line\nsecond line"),
(2, "comma, inside")]:
path = Path("parts") / f"part-{number}.csv"
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["id", "note"])
writer.writerow([f"{number:03d}", note])
PY_DATA
这段代码故意先创建 10 号分片,再创建 1 和 2;数据包含前导零、逗号与字段内换行。
- 将下一节完整代码保存为
merge_csv.py,也可下载完整脚本。
3. 完整实现:逐条读取,成功后替换
"""Merge numbered CSV shards in numeric filename order. Python 3.9+."""
import argparse
import csv
import os
from pathlib import Path
import re
import sys
import tempfile
NAME = re.compile(r"part-([0-9]+)\.csv")
def ordered_parts(directory):
parts = []
seen = set()
for path in directory.glob("part-*.csv"):
match = NAME.fullmatch(path.name)
if match is None or path.is_symlink() or not path.is_file():
raise ValueError(f"invalid shard: {path.name}")
number = int(match[1])
if number < 1 or number in seen:
raise ValueError(f"nonpositive or duplicate shard number: {path.name}")
seen.add(number)
parts.append((number, path))
if not parts:
raise ValueError("no part-<positive integer>.csv files")
return sorted(parts, key=lambda item: item[0])
def merge_csv(directory, output):
directory = Path(directory).expanduser().resolve(strict=True)
if not directory.is_dir():
raise ValueError("input must be a directory")
output = Path(output).expanduser().absolute()
output = output.parent.resolve(strict=True) / output.name
if output.parent == directory or output.is_symlink():
raise ValueError("output must be outside input directory and not a symlink")
parts = ordered_parts(directory)
temporary = None
count = 0
expected = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="", dir=output.parent,
prefix=f".{output.name}.", suffix=".tmp", delete=False,
) as dest:
temporary = Path(dest.name)
writer = csv.writer(dest, lineterminator="\n")
for number, path in parts:
with path.open(encoding="utf-8-sig", newline="") as source:
reader = csv.reader(source, strict=True)
try:
header = next(reader, None)
if not header or any(not field.strip() for field in header):
raise ValueError(f"{path.name}: missing or empty header")
if len(set(header)) != len(header):
raise ValueError(f"{path.name}: duplicate column names")
if expected is None:
expected = header
writer.writerow(header)
elif header != expected:
raise ValueError(f"{path.name}: header mismatch")
for row in reader:
if len(row) != len(expected):
raise ValueError(
f"{path.name}: physical line {reader.line_num}: "
f"expected {len(expected)} fields, got {len(row)}"
)
writer.writerow(row)
count += 1
except csv.Error as exc:
raise ValueError(
f"{path.name}: physical line {reader.line_num}: {exc}"
) from exc
os.replace(temporary, output)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return len(parts), count, [number for number, path in parts]
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("directory", type=Path, help="directory of completed shards")
parser.add_argument("output", type=Path, help="destination outside that directory")
args = parser.parse_args()
try:
files, records, order = merge_csv(args.directory, args.output)
except (OSError, ValueError, RuntimeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"OK: files={files} records={records} order={','.join(map(str, order))}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
CSV 官方文档要求文件对象使用 newline="",让 CSV 模块处理记录边界;默认读出的字段是字符串,所以 001 不会变成 1。strict=True 检查解析错误,表头和字段数则由我们额外检查。reader.line_num 是物理行位置,不是数据记录编号。
临时文件通过 NamedTemporaryFile 创建在输出旁边,关闭后调用 os.replace()。该接口在成功时原子替换目标,同目录避免了跨文件系统移动;finally 清理普通异常留下的临时文件。这个模式也用于原子保存 JSON。
这里没有 fsync,不承诺断电持久性,也不是并发事务。强制终止可能留下隐藏临时文件;确认没有运行中的合并进程后,再清理本次对应的文件。Linux 上临时文件通常是 0600,替换后不会继承旧文件的权限或 ACL;需要共享读取时,请另行设置明确的权限策略。
内存主要用于分片路径列表和当前记录,不保存全部数据;超长字段仍受 CSV 解析器上限约束。输出为无 BOM 的 UTF-8,记录分隔符是 LF,字段值保留,但原始引号写法和字节格式可能变化。
4. 运行:核对编号顺序和记录内容
- 查看帮助,然后合并:
python3 merge_csv.py --help
python3 merge_csv.py parts merged.csv
预期输出:
OK: files=3 records=3 order=1,2,10
- 不用
wc -l判断记录数,直接解析输出并断言:
python3 - <<'PY_CHECK'
import csv
with open("merged.csv", encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
assert rows == [
["id", "note"],
["001", "first line\nsecond line"],
["002", "comma, inside"],
["010", "last"],
]
print("PASS: numeric order, one header, and multiline values")
PY_CHECK
应看到 PASS: numeric order, one header, and multiline values。这同时确认:表头只有一份,数据顺序是 1、2、10,字段内的换行和逗号没有被拆坏。
5. 验证失败:旧输出不能变成半成品
- 先完成上一节,再临时修改最后一个分片的表头,检查退出码和旧文件;测试结束会恢复原始分片:
python3 - <<'PY_FAIL'
from pathlib import Path
import subprocess
import sys
output = Path("merged.csv")
before = output.read_bytes()
bad = Path("parts/part-10.csv")
original = bad.read_bytes()
try:
bad.write_text("note,id\nwrong,010\n", encoding="utf-8")
result = subprocess.run(
[sys.executable, "merge_csv.py", "parts", "merged.csv"],
capture_output=True, text=True,
)
assert result.returncode == 1, result
assert result.stdout == "", result.stdout
assert "header mismatch" in result.stderr, result.stderr
assert output.read_bytes() == before
assert not list(Path(".").glob(".merged.csv.*.tmp"))
print("PASS: header mismatch returns 1; previous output unchanged")
finally:
bad.write_bytes(original)
PY_FAIL
预期打印 PASS: header mismatch returns 1; previous output unchanged。这是合并读到后续坏文件时的保护,不只是启动前的文件名检查。
- 再测试同一数字的不同拼写。仅在这个新建的演示目录运行:
cp parts/part-1.csv parts/part-01.csv
python3 merge_csv.py parts merged.csv
printf 'exit=%s\n' "$?"
rm parts/part-01.csv
应看到包含 duplicate shard number 的错误,以及 exit=1。报错可能点名 1 或 01,取决于枚举顺序,但无论哪种情况都拒绝合并。最后一条只删除本次测试创建的副本。
成功返回 0;输入、校验或文件操作失败返回 1,错误写到 stderr;命令行用法错误由 argparse 返回 2。用于自动化时用 python3 merge_csv.py parts merged.csv && 下一条命令 的结构串联,不要用 ; 无条件启动消费者。
6. 排障:错误对应具体动作
| 错误或现象 | 处理方式 |
|---|---|
invalid shard |
检查匹配到的文件名;把旧分片移到输入目录外,拒绝符号链接。 |
nonpositive or duplicate shard number |
用正整数编号,每个数字只保留一份;不要靠补零制造新编号。 |
header mismatch |
对照列名与顺序修复上游导出,可先做 CSV 结构校验;不要静默丢列。 |
expected ... fields / CSV 解析错误 |
根据文件名与物理行定位坏记录;带换行的字段可能跨越多行。 |
| UTF-8 解码失败 | 先确认并严格转换来源编码,不用 errors="ignore" 掩盖数据损坏。 |
| 输出路径错误或替换失败 | 创建正确的父目录,使用输入目录之外的普通目标路径,并检查目录写权限与剩余空间。 |
如果总记录数不符合预期,先核对上游清单或完成标记。该工具允许编号空缺,也不按文件名推断数据完整性;不要把“合并成功”当成“数据全部到齐”。
7. 总结
合并分片前先把数字编号、重复编号和表头规则写清楚,再用 CSV 解析器逐条读取。临时文件验证完成后才替换目标,可以避免后续坏分片把上一份完整输出截断。用记录级断言和失败测试检查流程,比只看文件大小或文本行数可靠。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-merge-csv-numeric-order.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。