PythonでCSVを安全に置き換える ― 書込み途中で失敗しても元データを残す

プログラミング・Web開発カテゴリを表すパンダのイラスト プログラミング・Web開発

この記事について
この記事は、生成AIを活用した自動生成フローで作成しています。Python公式資料のcsv、tempfile、os.replaceを参照し、途中エラーの際に元CSVを残すコードを作りました。Python 3.13.5でサンプル実行、構文チェック、3件の単体テストを実施しています。

検証ステータス:🧪 Python 3.13.5でローカル実行・単体テスト3件成功
WordPressや業務システムへの組込み動作、Windows環境、突然の電源断については未検証です。

PythonでCSVを更新する際に、既存ファイルへ直接書き込んでしまうと、途中で例外が起きたときに中途半端なファイルが残る可能性があります。

新しい内容を一時ファイルへ最後まで書き込んでから置き換えると、少なくとも通常の書込み例外によって元ファイルを途中まで上書きしてしまう事態を避けやすくなります。

ここでは、標準ライブラリだけを使って、正常終了時には新CSVへ置換し、検証エラー時には元CSVを保持するプログラムを試します。保存先は毎回作成する学習用の一時ディレクトリなので、業務データには触れません。

直接書き込む方法と何が違う?

次のように既存のCSVを開くと、ファイルの内容は書込み開始時に切り詰められます。

# 注意: 既存のファイルを直接書き換える例

with open("batch.csv", "w", encoding="utf-8", newline="") as fp:
    fp.write("id,status\n")

    # ここで処理が停止すると、元データは戻りません。

「書込みに成功したときだけ新しい内容へ切り替える」なら、先に別ファイルを作る方法が有効です。

処理直接上書き一時ファイルを使う方法
書込み開始元ファイルへ書込む一時ファイルへ書込む
途中で検証エラー元ファイルが途中まで変わり得る置換前なら元ファイルを保持できる
完了時そのまま終了os.replaceで置換
注意点部分書込み同時更新や電源断への追加対策は別途必要

os.replaceの成功時にファイル名を置換する操作は、POSIX環境ではアトミック操作として定義されています。ただし、ファイル名の置換がアトミックであることと、突然の電源断の後にも必ず新データが残ることは別の話です。公式資料はPython os.replaceです。

動作確認したサンプル

Python 3.10以降を対象としたコードです。標準ライブラリのcsv、os、tempfile、pathlibを使います。

from __future__ import annotations

import csv
import os
import tempfile
from pathlib import Path
from collections.abc import Iterable, Mapping

COLUMNS = ("id", "status")


def replace_csv(target: Path, rows: Iterable[Mapping[str, str]]) -> None:
    """CSVを書き終えるまで元ファイルを変更しない。"""
    target = Path(target)
    temporary: Path | None = None
    try:

        # 置換先と同じフォルダーに一時ファイルを作成する

        with tempfile.NamedTemporaryFile(
            mode="w", encoding="utf-8", newline="", delete=False,
            dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
        ) as fp:
            temporary = Path(fp.name)
            writer = csv.DictWriter(fp, fieldnames=COLUMNS, extrasaction="raise")
            writer.writeheader()

            for row in rows:

                # 入力列の過不足も検出し、失敗したら置換しない

                if set(row) != set(COLUMNS):
                    raise ValueError("CSV row must have exactly id and status")
                writer.writerow(row)

            # Pythonのバッファを出し、OSにファイル内容の同期を依頼

            fp.flush()
            os.fsync(fp.fileno())

        # 全件を書き終えてから、元CSVの名前へ置き換える

        os.replace(temporary, target)
        temporary = None
    finally:

        # 置換前に失敗したら、作った一時ファイルだけを削除

        if temporary is not None:
            temporary.unlink(missing_ok=True)


if __name__ == "__main__":

    # 毎回新しいディレクトリにサンプルCSVを作る

    with tempfile.TemporaryDirectory(prefix="csv-atomic-demo-") as folder:
        csv_path = Path(folder) / "batch.csv"
        csv_path.write_text("id,status\nold,OLD\n", encoding="utf-8")

        replace_csv(csv_path, (
            {"id": "A-001", "status": "DONE"},
            {"id": "A-002", "status": "PENDING"},
        ))

        with csv_path.open(newline="", encoding="utf-8") as fp:
            entries = list(csv.DictReader(fp))
        print(f"[RESULT] rows={len(entries)} ids={[x['id'] for x in entries]}")

GitHubに置いた実行ファイルも同じ内容です。

サンプルのフォルダーで、ターミナルから次を実行できます。

python3 atomic_csv.py
python3 -m unittest -v test_atomic_csv.py

実際に確認した出力

Python 3.13.5のテスト環境では、次の結果が出ました。

[RESULT] rows=2 ids=['A-001', 'A-002']
Ran 3 tests in 0.002s
OK

3つのテストは、初回作成と既存ファイル置換、途中エラー時の元ファイル維持、値に改行が含まれる場合のCSV読込を対象にしています。テストの処理時間は環境によって変わります。

書込み処理の順序

  1. 一時ファイルを同じディレクトリに作成する

  2. 全レコードを書き込む

  3. 途中の列不一致をエラーとして検出する

  4. flushとfsyncを実施する

  5. 一時ファイルを閉じる

  6. os.replaceで置換する

  7. エラー時は置換前の一時ファイルを後片付けする

同じディレクトリに作る理由

NamedTemporaryFileのdirにtarget.parentを指定しています。

with tempfile.NamedTemporaryFile(
    mode="w",
    encoding="utf-8",
    newline="",
    delete=False,
    dir=target.parent
) as fp:
    ...

一時ファイルをシステムの既定tempディレクトリに作ると、保存先とは別のファイルシステムになる可能性があります。os.replaceは異なるファイルシステム間では失敗することがあるため、同じディレクトリに作ります。

delete=Falseを指定した一時ファイルは、プログラムが閉じただけでは削除されません。そこで例外が起きたときにfinallyで削除する処理も必要です。仕様はPython tempfileで確認できます。

newlineを空文字にする理由

CSVでは、セルの中に改行が含まれる場合があります。Pythonのcsvモジュールは、そのような値を引用符で囲むなど、CSVの改行を処理します。

with csv_path.open(encoding="utf-8", newline="") as fp:
    rows = list(csv.DictReader(fp))

Python公式のcsvドキュメントでも、ファイルを扱う際にnewline=””を指定する例が示されています。OSの改行変換とCSV側の処理を重複させないためです。

わざと失敗させても元CSVは残る?

次のテストでは、すでにあるCSVへ置き換えようとしますが、2件目の列が不足しています。

from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as folder:
    path = Path(folder) / "batch.csv"
    path.write_text("id,status\nOLD,OK\n", encoding="utf-8")

    try:
        replace_csv(path, (
            {"id": "A", "status": "DONE"},
            {"id": "MISSING"},
        ))
    except ValueError as error:
        print(f"[EXPECTED_ERROR] {error}")

    print(path.read_text(encoding="utf-8"))

期待結果は次のとおりです。

[EXPECTED_ERROR] CSV row must have exactly id and status
id,status
OLD,OK

これを実際の単体テストでも確認しています。置換が終わる前に例外が発生するため、元CSVの内容は変わりません。書込み途中の一時ファイルはfinallyで削除されます。

ただし、失敗したレコードだけを除いて処理を続ける実装ではありません。異常な入力があったらCSV全体の置換を中止するという設計です。

実際の運用で気を付けること

よくある状況対応の考え方
別のプログラムも書き込むファイルロック等を追加し、競合を制御する
WindowsでCSVがExcelに開かれている置換が拒否される可能性を考え、エラー処理と再実行手順を作る
ディスク容量が不足書込みエラーとして停止し、元データを保全する
電源が突然切れるfsyncやディレクトリ同期、ストレージの耐障害性を別途検討する
既存CSVに重要データがあるバックアップと更新内容の検証を先に行う
大きなCSVを扱う一時ファイル分のディスク容量を確保する

この方法は単一の書込み担当が、一つのCSVファイルを安全側で更新する用途に向きます。データベースのトランザクション、同時実行制御、停電後の復元までを保証するものではありません。

本番へ導入する場合は、同じディレクトリの権限、ファイル所有者、既存ファイルのモード、バックアップ、ロールバックを含めて設計してください。os.replaceを使うと新しい一時ファイルが置換後のファイルになるため、元ファイルのアクセス権などがそのまま引き継がれると仮定しないことも重要です。

参照した公式資料

文書情報

記事タイトル
PythonでCSVを安全に置き換える ― 書込み途中で失敗しても元データを残す
作成日
更新日
Source URL
https://papanda925.com/?p=18066

ライセンス: 本記事のうち、当サイトが権利を有する本文・自作図表は、特記なき限り CC BY 4.0 で利用できます。生成AIを活用して作成・編集した内容を含みます。コードについて、別途ライセンス表示またはリンク先GitHubリポジトリのライセンスがある場合は、その条件を優先します。引用・第三者資料・画像・商標等は本ライセンスの対象外です。 利用ポリシー

タイトルとURLをコピーしました