0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSON変換の失敗で元ファイルを壊さない:一時ファイルとos.replaceで保存する

0
Posted at

JSONを保存するとき、保存先をwモードで開いてからjson.dump()を呼ぶことがあります。しかし、JSONへ変換できない値が途中に含まれていると、例外が起きた時点で元ファイルの内容はすでに失われています。

この記事では、一時ファイルへの書き込みを完了してから保存先を置き換える方法を、標準ライブラリだけで確認します。実案件の体験談ではなく、この記事用に作成・実行した最小例です。

検証環境はmacOS、Python 3.14.5です。

直接上書きすると、変換に失敗したあと元データが残らない

空の検証用ディレクトリで、次をbad.pyとして実行してください。サンプルはその場所のstate.jsonを書き換えます。

import json
from pathlib import Path

path = Path("state.json")
path.write_text('{"version": 1}\n', encoding="utf-8")

try:
    with path.open("w", encoding="utf-8") as file:
        json.dump({"values": {1, 2}}, file)
except TypeError:
    print("serialization failed")

print(repr(path.read_text(encoding="utf-8")))
python3 bad.py

出力は次のとおりでした。

serialization failed
'{"values": '

setは、このコードの設定ではJSONに変換できません。元の{"version": 1}は残らず、途中まで書かれた文字列が保存されていました。

例外を捕まえるだけでは、上書き前のファイルに戻せません。

一時ファイルに書いてから保存先を置き換える

次をsafe_json.pyとして保存します。

import json
import os
import tempfile
from pathlib import Path


def save_json(path, data):
    path = Path(path)
    temporary_path = None

    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=path.parent,
            prefix=f".{path.name}.",
            suffix=".tmp",
            delete=False,
        ) as file:
            temporary_path = Path(file.name)
            json.dump(data, file, ensure_ascii=False, indent=2)
            file.write("\n")

        os.replace(temporary_path, path)
    finally:
        if temporary_path is not None:
            temporary_path.unlink(missing_ok=True)

保存先を先に開かず、一時ファイルの書き込みとクローズが終わってからos.replace()を呼びます。

JSON変換が失敗すれば、置き換えの行へ到達しません。失敗は呼び出し元へ伝わり、元ファイルはそのまま残ります。finallyでは残った一時ファイルを削除します。置き換えが成功した場合は一時ファイルの元のパスがなくなるため、missing_ok=Trueでその状態を許容しています。

NamedTemporaryFileはdelete=Falseにして、クローズ後にもファイルを残しています。自動削除に任せず、自分で置き換えと後片づけを行うためです。tempfileの公式資料に削除設定が説明されています。

一時ファイルを保存先と同じディレクトリに作る理由

dir=path.parentは意図的な指定です。

os.replace()は、置き換え元と先が異なるファイルシステムにあると失敗する場合があります。同じディレクトリに一時ファイルを作り、その問題を避ける構成にしています。

成功したrenameは原子的な操作であることがos.replaceの公式資料に説明されています。ここで原子的というのは、保存先を途中まで直接上書きする操作とは異なり、ファイルの置き換えをひとまとまりに行うという意味です。

成功と失敗を確認する

次をverify.pyとして、safe_json.pyと同じ場所に保存してください。データは一時ディレクトリの中だけで作成します。

import json
import tempfile
from pathlib import Path
from unittest.mock import patch

from safe_json import save_json


with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / "state.json"
    old = '{"version": 1}\n'
    path.write_text(old, encoding="utf-8")

    # JSONに変換できない値を渡す。
    try:
        save_json(path, {"values": {1, 2}})
    except TypeError:
        pass
    else:
        raise AssertionError("TypeError was not raised")

    assert path.read_text(encoding="utf-8") == old
    assert sorted(p.name for p in path.parent.iterdir()) == ["state.json"]
    print("serialization failure: original retained")

    # 置き換え失敗を模擬する。OSの権限を変更する検証ではない。
    with patch("safe_json.os.replace", side_effect=PermissionError("blocked")):
        try:
            save_json(path, {"version": 2})
        except PermissionError:
            pass
        else:
            raise AssertionError("PermissionError was not raised")

    assert path.read_text(encoding="utf-8") == old
    assert sorted(p.name for p in path.parent.iterdir()) == ["state.json"]
    print("replacement failure: original retained")

    save_json(path, {"version": 2, "label": "更新済み"})
    assert json.loads(path.read_text(encoding="utf-8")) == {
        "version": 2,
        "label": "更新済み",
    }
    assert sorted(p.name for p in path.parent.iterdir()) == ["state.json"]
    print("success: new JSON saved")
python3 verify.py

実行結果:

serialization failure: original retained
replacement failure: original retained
success: new JSON saved

直接上書きの例を含め、次の4ケースを確認しました。

ケース 保存先の結果 残った一時ファイル
直接上書き中にJSON変換失敗 元データを失い、不完全なJSONになる 使用なし
一時ファイルへのJSON変換失敗 元の文字列を保持 なし
os.replaceが例外を送出する状況をモックで再現 元の文字列を保持 なし
正常保存 日本語を含む新しいJSONを読み込める なし

このサンプルの適用範囲

この実装は、JSON変換や置き換えの例外が起きたときに、保存先を途中まで上書きしないための例です。停電時の永続化を保証する実装ではなく、fsync()やディレクトリの同期は行っていません。

また、既存ファイルの権限や所有者などのメタデータを引き継ぐ処理、複数プロセスによる更新の排他制御は含めていません。Windows、ネットワークファイルシステム、実際のディスク障害では未検証です。一時ファイルの削除自体が失敗する場合も、追加の対応が必要です。

保存先は、親ディレクトリが存在する通常のファイルを想定しています。大切なデータへ適用する前に、その環境で必要な権限と障害時の扱いを確認してください。

保存処理を「新しい内容を作る段階」と「保存先を置き換える段階」に分けると、変換失敗で古いデータまで失う問題を避けやすくなります。

参照資料

確認日:2026年9月28日。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?