Запись и чтение JSON-файлов в Python

JSON стал стандартным форматом для настроек, кэша, обмена данными между сервисами и выгрузок из API. В Python для работы с ним есть встроенный модуль json, и базовая запись или чтение файла занимает три строки. На практике сразу возникают вопросы: почему вместо русских букв в файле П, как сохранить дату, что делать с ошибкой Expecting value и как дописывать данные в файл, не перечитывая его целиком. Разберем все по шагам.
Как записать данные в JSON-файл
Для записи используют json.dump(obj, file). Файл открывают на запись в текстовом режиме и обязательно с явной кодировкой.
import json
user = {
"name": "Анна",
"age": 29,
"skills": ["python", "sql"],
"active": True,
"manager": None,
}
with open("user.json", "w", encoding="utf-8") as f:
json.dump(user, f, ensure_ascii=False, indent=2)
Содержимое файла user.json:
{
"name": "Анна",
"age": 29,
"skills": [
"python",
"sql"
],
"active": true,
"manager": null
}
Обратите внимание: True превратился в true, None в null, строки стоят в двойных кавычках. Это уже не Python-словарь, а текст в формате JSON. Режим "w" перезаписывает файл целиком, о дозаписи поговорим в разделе про JSON Lines.
Кириллица и параметр ensure_ascii
По умолчанию ensure_ascii=True, и все символы вне ASCII заменяются escape-последовательностями:
print(json.dumps({"name": "Привет"}))
# -> {"name": "Привет"}
print(json.dumps({"name": "Привет"}, ensure_ascii=False))
# -> {"name": "Привет"}
Оба варианта корректный JSON, и при загрузке получится одна и та же строка. Но файл с ensure_ascii=False можно читать глазами и он занимает меньше места. Если вы указываете ensure_ascii=False, файл обязательно открывайте с encoding="utf-8". Иначе на Windows Python возьмет системную кодировку cp1251 и либо испортит символы вне ее, либо выбросит UnicodeEncodeError на эмодзи. Подробнее о кодировках строк и байтов рассказано в статье про кодировку строки.
Форматирование: indent, sort_keys, separators
| Параметр | Что делает | Когда использовать |
|---|---|---|
indent=2 или 4 | переносы строк и отступы | конфиги и файлы, которые читают люди |
indent="\t" | отступ табуляцией | если так принято в проекте |
sort_keys=True | сортирует ключи словарей | файлы в git: стабильный порядок дает чистые диффы |
separators=(",", ":") | убирает пробелы после запятых и двоеточий | компактная передача по сети, большие выгрузки |
ensure_ascii=False | пишет символы как есть | почти всегда для русскоязычных данных |
default=func | сериализация нестандартных типов | даты, Decimal, свои классы |
data = {"b": 1, "a": [1, 2]}
print(json.dumps(data, separators=(",", ":"), sort_keys=True))
# -> {"a":[1,2],"b":1}
Как прочитать JSON-файл
Чтение выполняет json.load(file). Результат это обычные объекты Python: словари, списки, строки и числа.
import json
with open("user.json", encoding="utf-8") as f:
user = json.load(f)
print(user["name"], user["skills"][0]) # -> Анна python
print(type(user)) # -> <class 'dict'>
Соответствие типов при чтении и записи:
| JSON | Python при чтении | Python при записи |
|---|---|---|
object {} | dict | dict |
array [] | list | list, tuple |
| string | str | str |
| number (целое) | int | int |
| number (дробное) | float | float |
true / false | True / False | bool |
null | None | None |
Из таблицы следуют две ловушки при "круговом" сохранении данных. Кортеж после записи и чтения становится списком. Ключи словаря в JSON всегда строки, поэтому числовые ключи превращаются в строковые:
scores = {1: "золото", 2: "серебро"}
restored = json.loads(json.dumps(scores))
print(restored) # -> {'1': 'золото', '2': 'серебро'}
print(restored.get(1)) # -> None, ключ теперь "1"
Если исходные ключи были числами, восстановите их явно: {int(k): v for k, v in restored.items()}.
Короткий вариант через pathlib
from pathlib import Path
path = Path("settings.json")
settings = json.loads(path.read_text(encoding="utf-8"))
path.write_text(json.dumps(settings, ensure_ascii=False, indent=2), encoding="utf-8")
Для небольших файлов это удобнее, чем блок with. Общие правила открытия файлов, режимы и работа с контекстным менеджером описаны в материале о чтении и записи файлов.
Разница между dump и dumps, load и loads
Буква s на конце означает string. Функции без нее работают с файлом, функции с ней работают со строкой.
| Функция | Вход | Выход |
|---|---|---|
json.dump(obj, f) | объект Python и открытый файл | пишет в файл, возвращает None |
json.dumps(obj) | объект Python | строка JSON |
json.load(f) | открытый файл | объект Python |
json.loads(s) | строка или байты JSON | объект Python |
Частая ошибка новичков: json.load("user.json"). Функция ждет файловый объект, а не имя файла, и выбросит AttributeError: 'str' object has no attribute 'read'. Остальные возможности модуля, включая разбор строк из API, собраны в обзоре модуля json.
Сохранение datetime, Decimal и своих классов
Модуль знает только базовые типы. Попытка сохранить дату дает ошибку:
from datetime import datetime
json.dumps({"created": datetime(2026, 10, 25, 14, 30)})
# -> TypeError: Object of type datetime is not JSON serializable
Параметр default
Функция в default вызывается для каждого объекта, который модуль не умеет сериализовать. Она должна вернуть что-то сериализуемое или выбросить TypeError.
from datetime import date, datetime
from decimal import Decimal
from dataclasses import dataclass, asdict, is_dataclass
from pathlib import Path
def to_json(obj):
if isinstance(obj, date): # покрывает и datetime
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj) # строка, чтобы не потерять точность
if isinstance(obj, (set, frozenset)):
return sorted(obj)
if isinstance(obj, Path):
return str(obj)
if is_dataclass(obj):
return asdict(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
@dataclass
class Order:
id: int
total: Decimal
created: datetime
order = Order(7, Decimal("1999.90"), datetime(2026, 10, 25, 14, 30))
print(json.dumps(order, default=to_json, ensure_ascii=False))
# -> {"id": 7, "total": "1999.90", "created": "2026-10-25T14:30:00"}
Обратите внимание на последнюю строку функции. Если вместо исключения вернуть str(obj) для всего подряд, ошибки типов будут молча превращаться в строки вида "<object at 0x...>", и вы узнаете об этом только при чтении.
Обратное преобразование через object_hook
При чтении дата останется строкой. Чтобы получить объекты обратно, используйте object_hook: функцию, которую json.load вызывает для каждого прочитанного словаря.
def from_json(d):
for key, value in d.items():
if key in ("created", "updated") and isinstance(value, str):
d[key] = datetime.fromisoformat(value)
return d
raw = '{"id": 7, "created": "2026-10-25T14:30:00"}'
obj = json.loads(raw, object_hook=from_json)
print(obj["created"].year) # -> 2026
Для денежных сумм удобен параметр parse_float=Decimal: все дробные числа сразу читаются как Decimal без потери точности. Для сложных моделей со вложенными типами и проверкой данных стоит посмотреть на Pydantic, который сериализует и валидирует JSON по описанию класса.
Как обновить JSON-файл безопасно
JSON нельзя изменить "на месте": нужно прочитать файл, поменять объект и записать заново. Если программа упадет во время записи, файл останется обрезанным и при следующем запуске не прочитается. Надежный прием: писать во временный файл рядом и атомарно заменять оригинал.
import json
import os
import tempfile
from pathlib import Path
def save_json(path, data):
path = Path(path)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path) # атомарная замена
except BaseException:
os.unlink(tmp)
raise
def load_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
counters = load_json("stats.json", default={})
counters["visits"] = counters.get("visits", 0) + 1
save_json("stats.json", counters)
Функция os.replace заменяет файл за одну операцию файловой системы: читатель увидит либо старую, либо новую версию, но не половину.
JSON Lines: формат для логов и больших выгрузок
Обычный JSON-файл это один объект, и чтобы добавить запись в список, приходится перезаписывать весь файл. Формат JSON Lines (расширение .jsonl) хранит по одному JSON-объекту на строку. Его можно дописывать в конец и читать построчно, не загружая в память целиком.
import json
events = [
{"user": "anna", "action": "login"},
{"user": "boris", "action": "buy", "sum": 450},
]
# дозапись в конец
with open("events.jsonl", "a", encoding="utf-8") as f:
for e in events:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
# построчное чтение
def read_jsonl(path):
with open(path, encoding="utf-8") as f:
for line_no, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError as e:
print(f"строка {line_no} повреждена: {e.msg}")
total = sum(e.get("sum", 0) for e in read_jsonl("events.jsonl"))
print(total) # -> 450
При записи JSON Lines нельзя использовать indent: каждая запись должна занимать ровно одну строку. Зато поврежденная строка ломает только одну запись, а не весь файл. Другие приемы обработки файлов на гигабайты описаны в статье про чтение больших файлов.
Ошибка JSONDecodeError и ее причины
json.JSONDecodeError это подкласс ValueError. У исключения есть полезные атрибуты: msg (описание), lineno и colno (строка и колонка), pos (позиция символа), doc (исходный текст).
| Сообщение | Частая причина | Решение |
|---|---|---|
| Expecting value: line 1 column 1 (char 0) | пустой файл или пустой ответ сервера, HTML вместо JSON | проверить размер файла и содержимое ответа |
| Expecting property name enclosed in double quotes | одинарные кавычки, то есть сохранен str(dict) | записывать через json.dump, а не str() |
| Unexpected UTF-8 BOM | файл сохранен в Блокноте с BOM | открыть с encoding="utf-8-sig" |
| Extra data | в файле несколько объектов подряд | это JSON Lines, читать построчно |
| Ошибка про запятую в конце | запятая после последнего элемента | убрать лишнюю запятую, формат ее не допускает |
try:
with open("config.json", encoding="utf-8-sig") as f:
config = json.load(f)
except FileNotFoundError:
config = {}
except json.JSONDecodeError as e:
raise SystemExit(f"config.json: {e.msg}, строка {e.lineno}, колонка {e.colno}")
Кодировка utf-8-sig читает файлы и с BOM, и без него, поэтому для конфигов, которые могут редактировать вручную на Windows, это безопасный выбор.
Отдельный случай: значения NaN и Infinity. Python по умолчанию записывает их в файл, но это нарушает стандарт, и парсеры других языков их отвергнут. Чтобы получить ошибку еще при записи, используйте allow_nan=False.
Типичные ошибки при работе с JSON-файлами
Ошибки с JSON редко бывают сложными, но часто проявляются не сразу, а через недели работы программы, когда в данных появляется неожиданный символ или файл вырастает.
Открытие файла на запись до чтения
Конструкция, в которой файл открывается в режиме "w", а потом из него же пытаются прочитать старые данные, стирает содержимое сразу при открытии. Режим "w" обнуляет файл еще до первой записи. Сначала прочитайте данные и закройте файл, затем откройте его на запись.
Несколько вызовов dump в один файл
Если вызвать json.dump несколько раз в цикле, в файле окажутся склеенные объекты вида {...}{...}. Такой файл не читается через json.load, ошибка будет "Extra data". Либо собирайте все данные в один список и записывайте один раз, либо пишите в формате JSON Lines с переводом строки после каждого объекта.
Потеря точности чисел
Дробные числа при чтении становятся float, и сумма 0.1 + 0.2 превращается в 0.30000000000000004. Для денег это недопустимо. Храните суммы строками или в копейках целым числом, а при чтении используйте parse_float=Decimal. Обратная проблема: большие целые числа Python запишет точно, но JavaScript на стороне клиента округлит все, что больше 2 в степени 53. Идентификаторы такого размера безопаснее передавать строками.
Доверие к данным из файла
Модуль json безопасен в том смысле, что не выполняет код при чтении, в отличие от pickle или eval. Но структура данных может быть любой: ключа может не оказаться, вместо списка может прийти строка, а файл может весить гигабайты. Проверяйте типы и наличие ключей через dict.get и isinstance или описывайте схему данных классом с валидацией.
JSON или другой формат
JSON не всегда лучший выбор для хранения данных. Для конфигурации, которую редактирует человек, удобнее TOML: он допускает комментарии и проще читается. Для табличных данных с одинаковыми полями компактнее CSV, который открывается в табличных редакторах. Для внутреннего кэша Python-объектов быстрее pickle, но его нельзя загружать из недоверенных источников. Для большого объема данных с запросами и обновлениями отдельных записей лучше подходит SQLite. JSON остается оптимальным, когда данные иерархические, их читают программы на разных языках и объем умеренный.
Практический ориентир: если JSON-файл приложения вырос до десятков мегабайт и при каждом изменении одной записи вы перезаписываете его целиком, пора переходить на SQLite. Если же файл служит для обмена с внешним сервисом или хранит настройки на несколько десятков строк, JSON подходит идеально, и усложнять решение нет смысла. Для промежуточного варианта, когда записи только добавляются и редко читаются целиком, хорошо работает JSON Lines.
Короткий чек-лист
- Всегда указывайте
encoding="utf-8"при открытии файла. - Для русских текстов добавляйте
ensure_ascii=False. - Для файлов, которые читают люди,
indent=2, для git еще иsort_keys=True. - Даты и Decimal сериализуйте через
default, а восстанавливайте черезobject_hook. - Важные файлы записывайте через временный файл и
os.replace. - Для логов и потоков данных используйте JSON Lines.
Частые вопросы
Как дописать данные в существующий JSON-файл?
Прочитать файл через json.load, добавить элемент в список или словарь и записать весь объект обратно. Открытие в режиме "a" и повторный json.dump создадут два объекта подряд, и файл перестанет читаться. Если дописывать нужно часто, переходите на JSON Lines.
Как прочитать очень большой JSON-файл?
json.load загружает весь документ в память, и объем памяти обычно в несколько раз больше размера файла. Для гигантских массивов используйте потоковый парсер, например пакет ijson, или конвертируйте данные в JSON Lines.
Почему нельзя сохранять словарь через str()?
str(dict) дает представление Python с одинарными кавычками, True и None. Это не JSON, и json.load его не прочитает. Для обмена данными всегда используйте json.dumps.
Сохраняется ли порядок ключей?
Да. Словари в Python сохраняют порядок вставки, json.dump записывает ключи в этом порядке, а json.load восстанавливает его. Изменить порядок можно параметром sort_keys=True.
Можно ли писать комментарии в JSON-файле?
Стандарт JSON комментарии не допускает, и json.load на них выдаст ошибку. Для конфигов с комментариями лучше подходит TOML: в Python 3.11+ его можно прочитать встроенным модулем tomllib.