Bobobk

Practical Python, Linux and automation guides

Merge CSV Shards in Numeric Order with Python: Headers and Failure-Safe Output

2026-10-29 Technology
Merge CSV Shards in Numeric Order with Python: Headers and Failure-Safe Output
On this page

Reassembling CSV shards with cat repeats their headers. Sorting filenames as strings can put part-10.csv before part-2.csv, and skipping physical lines can break fields containing newlines.

This beginner-to-intermediate tutorial builds a Python standard-library tool to merge CSV shards by the numeric part of their filenames. It determines the order, validates and streams records, and replaces the final file only after success. It follows splitting large CSV files by record, but also accepts matching shards from other tools.

1. Method: define ordering and input rules

Directory enumeration is not a business ordering rule. The official pathlib documentation describes iterdir() order as arbitrary. Even explicitly sorting filenames as strings produces:

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']

Instead of implementing general-purpose natural sorting, accept one explicit format:

  1. Inspect top-level entries matching part-*.csv. Each name must be exactly part-<positive integer>.csv, using ASCII digits. Reject part-old.csv, directories, and symlinks; ignore other nonmatching files.
  2. Convert the number to an integer before sorting. Accept part-0001.csv, but reject part-1.csv together with part-01.csv. Gaps are allowed: this does not establish that all shards have arrived.
  3. Inputs are comma-delimited, double-quoted UTF-8 CSV, optionally with a BOM. Header names cannot be blank or duplicated. Every file must have exactly the same column names in the same order.
  4. Each data record must match the header width. Blank records fail. Header-only shards are valid; completely empty files fail.
  5. Keep the output outside the input directory, with an existing parent directory and no destination symlink. Success replaces an existing destination; validation or write failures preserve the previous output.

Shards must be complete and remain unchanged during the merge. Use one output writer. When consuming the split tool above, check its _SUCCESS marker first. This merger does not check that marker or establish unique IDs across records.

2. Setup: create shards with special fields

Use an existing Python 3.9+ interpreter; no pip packages are needed. These examples were verified on Linux with Python 3.11.4. Shell commands work in Bash or zsh.

  1. Create a fresh practice directory. Choose another directory name when repeating the exercise, and run subsequent commands there:
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

The fixture deliberately creates shard 10 before 1 and 2. Values include leading zeros, a comma, and an embedded newline.

  1. Save the next section as merge_csv.py, or download the complete script.

3. Complete implementation: stream, then replace

"""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())

The CSV documentation specifies newline="" for file objects so the module handles record boundaries. Fields are strings by default, preserving 001. strict=True checks parsing errors; our code additionally checks headers and widths. reader.line_num reports a physical line position, not a record number.

NamedTemporaryFile creates a temporary file beside the destination. After closing it, os.replace() atomically replaces the destination on success; the same directory avoids a cross-filesystem move. The finally block removes temporary files after ordinary exceptions. The same pattern appears in atomic JSON writes.

There is no fsync, power-loss durability guarantee, or concurrent transaction. Forced termination can leave a hidden temporary file. Confirm no merge is running before removing its particular leftover file. On Linux, the temporary file normally has mode 0600; replacement does not inherit the old destination’s permissions or ACLs. Define a separate permission policy if other users need access.

Memory holds the shard path list and current record, not all records. Very long fields still encounter the CSV parser’s field-size limit. Output is UTF-8 without a BOM, with LF record separators. Parsed field values are preserved, but quoting and original bytes may change.

4. Run: verify numeric order and record values

  1. Check help, then merge:
python3 merge_csv.py --help
python3 merge_csv.py parts merged.csv

Expected output:

OK: files=3 records=3 order=1,2,10
  1. Do not count records with wc -l. Parse and assert the output instead:
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

Expect PASS: numeric order, one header, and multiline values. This checks a single header, record order 1, 2, 10, and intact embedded commas and newlines.

5. Verify failure: preserve the previous complete output

  1. Complete the previous section first. Temporarily change the last shard’s header and check the exit status and output bytes. The test restores the original shard afterward:
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

Expect PASS: header mismatch returns 1; previous output unchanged. This exercises a bad file encountered after earlier valid records, not merely a filename precheck.

  1. Test two spellings of the same number. Run this only in the fresh demo directory:
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

Expect an error containing duplicate shard number and exit=1. The error may name 1 or 01 depending on enumeration order; both cases reject the merge. The final command removes only the copy created by this test.

Success returns 0. Input, validation, and file-operation failures return 1 with errors on stderr. Invalid command-line usage returns 2 through argparse. In automation, chain the merger and your real consumer with &&, not ;, so a failed merge does not start the next task.

6. Troubleshooting: fix the specific cause

Error or symptom Action
invalid shard Check matching names. Move stale shards outside the input directory; do not use symlinks.
nonpositive or duplicate shard number Use positive integers and one file per number. Padding does not create a new number.
header mismatch Fix upstream column names and order. Run a CSV schema check first if needed; do not silently drop columns.
expected ... fields / CSV parsing error Locate the named file and physical line. A multiline field may span several lines.
UTF-8 decoding failure Identify and strictly convert the source encoding; do not hide corruption with errors="ignore".
Output path or replacement failure Use an existing parent and a regular destination outside the input directory. Check directory write access and free space.

For an unexpected record total, check the upstream manifest or completion marker. The tool allows numbering gaps and cannot infer completeness from filenames. A successful merge does not prove all data has arrived.

7. Summary

Define numeric ordering, duplicate-number handling, and header rules before merging shards. Stream records through the CSV parser, then replace the destination only after the temporary output is complete. Record-level assertions and failure tests are more useful than checking file sizes or physical line counts alone.

Tags

1024 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation background backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu monitoring cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download economics electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation fable faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filecmp filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak 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 multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print probability prompt-engineering 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 seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sql sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc trade tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode upsert urllib usdt utf-8 uv venv vhhbert vite wallet web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 迅雷解析

友情链接