
Как сохранить JSON-файл в Python так, чтобы сбой не повредил прежний результат


Если программа просто открывает итоговый JSON-файл на запись и начинает перезаписывать его с начала, то сбой в середине операции оставляет после себя пустой файл или обрывок текста. Для локального учебного сценария это самая неприятная ошибка: у вас уже была рабочая версия данных, но после исключения или внезапной остановки не осталось ни новой, ни старой.
Практически надежнее строить сохранение в три шага: сначала собрать полный JSON в памяти, потом записать его во временный файл рядом с итоговым, затем отдельно проверить, что временный файл читается как JSON, и только после этого заменить старый файл новым. Такой подход не делает чудес, но хорошо защищает прежний результат от частично записанного содержимого.
Что именно нужно сохранить от сбоя
В этой задаче цель узкая: не «сделать абсолютно неубиваемое хранилище», а не повредить предыдущую рабочую версию локального файла, если ошибка произошла до финальной подмены. Это важно сформулировать заранее, потому что от этого зависит архитектура. Нам не нужно многопользовательское редактирование, сеть, база данных или сложные журналы операций. Нам нужен понятный шаблон для одного JSON-файла на локальном диске.
Отсюда следуют три требования. Во-первых, старый файл нельзя открывать на запись до тех пор, пока новый вариант не готов полностью. Во-вторых, сериализацию лучше завершить до замены, чтобы ошибки типа TypeError или невалидных чисел не затронули итоговый путь. В-третьих, нужно проверять именно тот файл, который собираемся подставить на место старого, а не только исходный объект в памяти.
Здесь полезна одна особенность стандартного модуля json: он умеет выдавать строку целиком через json.dumps(), а при строгом режиме allow_nan=False не пропустит значения вроде NaN и Infinity. Это удобно для ранней остановки: если данные не укладываются в ожидаемый JSON, операция должна прерваться еще до работы с итоговым файлом.
Почему временный файл должен лежать рядом с итоговым
Идея «сначала записать во временный файл» работает не сама по себе, а вместе с важной деталью: временный файл создают в той же папке, что и итоговый. Причина не косметическая. Финальная подмена обычно делается через os.replace(), а такая замена надежнее предсказуема, когда источник и назначение находятся на одной файловой системе.
Если временный файл сделать в системной временной папке, а итоговый хранить, например, в другом разделе, можно получить сбой уже на этапе замены. Для учебной задачи это лишний риск: вы хотели защитить старую версию, а вместо этого добавили зависимость от расположения каталогов. Поэтому практическое правило простое: временный файл создается рядом с data.json, например как data.json.abcd1234.tmp.
Есть и вторая причина. Когда временный файл лежит рядом, его проще убрать после неудачной попытки и проще осмотреть руками при отладке. Вы видите и старый итоговый файл, и промежуточный артефакт в одном месте. Для учебного проекта это полезнее, чем прятать временные данные в другом каталоге и потом гадать, какой именно файл участвовал в замене.
Как выполнить запись, проверку и замену
Ниже показан учебный вариант для среды Python 3.11+ на локальной файловой системе. Он делает четыре вещи в правильном порядке: сериализует объект в строку, записывает эту строку во временный файл в той же папке, перечитывает временный файл через json.load() и только потом вызывает os.replace(). Если ошибка возникает раньше, старый итоговый файл остается нетронутым.
from __future__ import annotations
import json
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
def save_json_preserving_previous(path: str | Path, data, *, simulate_fail_at: str | None = None) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(
data,
ensure_ascii=False,
indent=2,
sort_keys=True,
allow_nan=False,
)
tmp_name = None
try:
with NamedTemporaryFile(
'w',
encoding='utf-8',
dir=target.parent,
prefix=target.name + '.',
suffix='.tmp',
delete=False,
) as tmp:
tmp_name = tmp.name
tmp.write(text)
tmp.flush()
os.fsync(tmp.fileno())
if simulate_fail_at == 'after_write':
raise RuntimeError('Учебный сбой после записи временного файла')
with open(tmp_name, 'r', encoding='utf-8') as check_file:
json.load(check_file)
if simulate_fail_at == 'before_replace':
raise RuntimeError('Учебный сбой перед заменой итогового файла')
os.replace(tmp_name, target)
except Exception:
if tmp_name:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
raise
Почему здесь выбран именно такой порядок. json.dumps() отлавливает проблемы сериализации до работы с целевым путем. json.load() на временном файле проверяет не абстрактный объект, а результат записи. os.replace() в конце подменяет старый файл новым одним действием, поэтому до этой точки прежняя версия еще доступна.
Отдельно стоит заметить, чего функция не делает. Она не пытается «дописывать по кускам» в уже существующий JSON-файл, потому что для одного документа это повышает риск получить обрезанный текст. Она также не обещает защиту от любых сценариев на свете: здесь решается более узкая задача — сохранить старую версию, пока новая не подтверждена и не подставлена на ее место.
УЧЕБНЫЙ ПРИМЕР: как смоделировать сбой и проверить результат
УЧЕБНЫЙ ПРИМЕР. Пусть есть файл lesson.json с исходными данными:
{
"version": 1,
"tasks": [
{"id": "a1", "status": "done"}
]
}
Мы хотим сохранить новую версию:
{
"version": 2,
"tasks": [
{"id": "a1", "status": "done"},
{"id": "b2", "status": "new"}
]
}
Сначала создаем исходный файл обычным успешным вызовом функции. Затем вызываем ее еще раз, но передаем simulate_fail_at='before_replace'. В этот момент временный файл уже записан и успешно читается, но подмена итогового файла еще не выполнена. Ожидаемый проверяемый результат такой: после исключения lesson.json по-прежнему содержит version = 1 и только одну задачу. После третьего вызова без симуляции сбоя файл должен уже содержать version = 2 и две задачи.
| Шаг | Что делаем | Что должно быть в lesson.json |
|---|---|---|
| 1 | Первое успешное сохранение исходной версии | version = 1, задач = 1 |
| 2 | Сбой перед os.replace | version = 1, задач = 1 |
| 3 | Повторный вызов без сбоя | version = 2, задач = 2 |
Практический смысл такого теста в том, что он отделяет «кажется, код надежный» от наблюдаемого результата. Если после шага 2 вы видите пустой файл, обрывок JSON или уже обновленную версию, значит порядок операций нарушен. Для учебной задачи это очень удобная самопроверка: прежняя версия либо действительно переживает сбой до замены, либо нет.
Границы метода: что защищено, а что нет
Важно не перепутать атомарную замену файла с атомарностью всей операции сохранения. Сериализация, запись временного файла и его проверка не являются одной неделимой операцией. Атомарным здесь считается только финальный переход от старого имени файла к новому содержимому через os.replace() при подходящих условиях файловой системы. Именно поэтому защита старой версии действует до замены, а не «на всех этапах сразу».
У метода есть рабочие границы. Он хорошо подходит для одного локального JSON-файла, который изменяет один процесс или понятный учебный скрипт. Он не решает конфликтов параллельной записи, не организует историю версий и не заменяет полноценное хранилище. Если два процесса одновременно готовят свои временные файлы и оба доходят до os.replace(), последний вызов просто перезапишет результат предыдущего.
Есть и зависимость от среды хранения. Даже аккуратная подмена имени файла не отменяет особенностей конкретной файловой системы и режима работы диска. Поэтому корректнее говорить так: этот шаблон заметно снижает риск повредить прежний результат в локальном учебном сценарии, но не дает безусловной гарантии на любой платформе и при любой аварии. Именно такая формулировка полезнее, чем обещание «полной неуязвимости».
Если вы хотите потренировать похожие задачи дальше, в сохраненном описании каталога курса «Python-разработчик с нуля» есть темы «Файлы», «JSON», модули и основы тестирования. Для этой темы особенно полезно сочетание: сначала записать файл, затем проверить чтение и смоделировать сбой отдельным тестовым сценарием.





















