JSON — это текст. Не структура данных в памяти, не объект, а строка по строгим правилам: двойные кавычки, никаких висящих запятых, конечный набор типов. Модуль json занимается ровно переводом между этим текстом и обычными объектами Python.
Функций для этого четыре, и почти вся путаница живёт в одной букве:
Четыре функции — две пары направлений
s — это string, а не «множественное число». dumps и loads работают со строкой, dump и load — с открытым файлом. Строка пути вместо открытого файла у dump или load вызывает AttributeError про отсутствующий метод write или read.Первое, обо что спотыкаются: кириллица
import json
print(json.dumps({"city": "Москва"}))
print(json.dumps({"city": "Москва"}, ensure_ascii=False))
{"city": "\u041c\u043e\u0441\u043a\u0432\u0430"}
{"city": "Москва"}
По умолчанию ensure_ascii=True, и всё, что вне ASCII, уезжает в escape-последовательности. Файл при этом остаётся валидным JSON и читается любым парсером обратно в «Москва» — сломано не содержимое, а читаемость. Но если файл будут открывать глазами или отдавать во внешнюю систему, ставь ensure_ascii=False и не забывай encoding="utf-8" при записи.
Файл целиком
import json
from pathlib import Path
data = {"city": "Москва", "users": [{"id": 1, "active": True}]}
Path("data.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(Path("data.json").read_text(encoding="utf-8"))
print("прочитано обратно:", json.loads(Path("data.json").read_text(encoding="utf-8"))["city"])
{
"city": "Москва",
"users": [
{
"id": 1,
"active": true
}
]
}
прочитано обратно: Москва
Обрати внимание на true в нижнем регистре: в JSON нет питоновских True, False и None — есть true, false и null, и перевод в обе стороны модуль делает сам.
Вариант через dump/load с открытым файлом делает то же самое:
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
with open("data.json", encoding="utf-8") as f:
data = json.load(f)
Подробнее про сами файлы, кодировки и Path — в разборе работы с файлами.
Круг «объект → JSON → объект» не всегда возвращает то же самое
Типов в JSON меньше, чем в Python, и лишние приводятся к ближайшим. Это не ошибка и не предупреждение — просто тихая потеря:
| Python | становится в JSON | что вернётся обратно |
|---|---|---|
dict | объект | dict |
list | массив | list |
tuple | массив | list — кортеж не восстановится |
str | строка | str |
int, float | число | int, float |
True / False | true / false | bool |
None | null | None |
ключ-int | ключ-строка | str — число ключом не вернётся |
import json
original = {1: "a", "point": (10, 20)}
restored = json.loads(json.dumps(original))
print("было: ", original)
print("стало:", restored)
было: {1: 'a', 'point': (10, 20)}
стало: {'1': 'a', 'point': [10, 20]}
Возврат из JSON не восстанавливает все типы Python
1 тихо стал '1', кортеж — списком. Если после чтения JSON data[user_id] выдаёт KeyError на существующем пользователе, причина обычно здесь.Чего модуль не умеет вовсе
datetime, Decimal, set и свои классы json не знает и честно падает:
import json, datetime, decimal
for value in [{1, 2}, datetime.date(2026, 8, 31), decimal.Decimal("10.50")]:
try:
json.dumps({"v": value})
except TypeError as exc:
print(f"{type(value).__name__:9} → TypeError: {exc}")
set → TypeError: Object of type set is not JSON serializable
date → TypeError: Object of type date is not JSON serializable
Decimal → TypeError: Object of type Decimal is not JSON serializable
Самый дешёвый выход — аргумент default: функция, которую модуль зовёт для всего, чего не понимает.
import json, datetime
print(json.dumps({"d": datetime.date(2026, 8, 31)}, default=str))
{"d": "2026-08-31"}
Для дат этого хватает: str() от date даёт ISO 8601, ровно тот формат, который ждут на другой стороне. Подробнее про форматы и часовые пояса — в разборе datetime.
С деньгами так делать нельзя. Соблазн привести Decimal к float заканчивается предсказуемо:
import json, decimal
print(json.dumps({"price": float(decimal.Decimal("10.50"))}))
{"price": 10.5}
Копейка не потерялась численно, но 10.50 превратилось в 10.5, а дальше арифметика пойдёт в двоичной плавающей точке со всеми её сюрпризами. Деньги в JSON передают строкой ("10.50") или целым числом копеек — и разбирают обратно в Decimal руками.
NaN: Python пишет то, чего в JSON нет
import json
print(json.dumps({"x": float("nan")}))
try:
json.dumps({"x": float("nan")}, allow_nan=False)
except ValueError as exc:
print(f"ValueError: {exc}")
{"x": NaN}
ValueError: Out of range float values are not JSON compliant: nan
NaN и Infinity в RFC 8259 не предусмотрены, но json.dumps по умолчанию их пишет. Получается текст, который Python прочитает, а строгий парсер на другом языке — нет. Если результат уходит наружу, ставь allow_nan=False и получай ошибку у себя, а не багрепорт от смежников.
JSONDecodeError: сообщение уже содержит адрес
Три самых частых поломки и их дословные сообщения:
import json
for text in ['{"a": 1,}', "{'a': 1}", ""]:
try:
json.loads(text)
except json.JSONDecodeError as exc:
print(f"{text!r:12} → {exc}")
'{"a": 1,}' → Illegal trailing comma before end of object: line 1 column 8 (char 7)
"{'a': 1}" → Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
'' → Expecting value: line 1 column 1 (char 0)
Читаются они так: висящая запятая — в JSON её нельзя, в отличие от Python; одинарные кавычки — их в JSON не бывает, только двойные, поэтому print() от словаря никогда не даёт валидный JSON; пустая строка — на разбор пришло ничего, обычно потому, что запрос вернул пустое тело или файл не прочитался.
У исключения есть поля с координатами, и на длинном документе они полезнее самого сообщения:
import json
try:
json.loads('{"name": "Аня", "age": }')
except json.JSONDecodeError as exc:
print(f"строка {exc.lineno}, колонка {exc.colno}, символ {exc.pos}")
print("вокруг:", repr(exc.doc[max(0, exc.pos - 12):exc.pos + 8]))
строка 1, колонка 24, символ 23
вокруг: 'ня", "age": }'
Extra data: это не один JSON, а несколько
import json
lines = '{"id": 1}\n{"id": 2}\n{"id": 3}'
try:
json.loads(lines)
except json.JSONDecodeError as exc:
print(f"целиком → {exc}")
print("построчно →", [json.loads(line)["id"] for line in lines.splitlines()])
целиком → Extra data: line 2 column 1 (char 10)
построчно → [1, 2, 3]
Сообщение Extra data почти всегда означает формат JSON Lines: по одному объекту на строку, без объемлющего массива. Так пишут логи и выгрузки, потому что такой файл можно читать построчно, не держа в памяти целиком. Разбирать его надо построчно — json.loads на весь файл сразу не рассчитан.
После разбора это обычный словарь
import json
data = json.loads('{"user": {"name": "Аня"}}')
print(data["user"].get("city"))
try:
print(data["user"]["city"])
except KeyError as exc:
print(f"KeyError: {exc}")
None
KeyError: 'city'
Никакой особой «JSON-структуры» после разбора нет — есть вложенные dict и list. Поэтому и правила те же: get() с запасным значением вместо квадратных скобок там, где ключа может не быть, и get("a", {}).get("b") для цепочек. Всё это подробно разобрано в статье про словари, а 'NoneType' object has no attribute 'get' посреди такой цепочки — в разборе AttributeError.
Частые вопросы
Чем dumps отличается от dump
dumps возвращает строку, dump пишет в открытый файл и возвращает None. Буква s означает string. Симметрично: loads разбирает строку, load читает из файла. Если передать строку пути вместо открытого файла в dump или load, будет AttributeError про отсутствующий write или read. Неподходящий тип аргумента у loads, например открытый файл вместо строки, даёт TypeError.
Почему в файле вместо русских букв последовательности \u041c\u043e
Сработал ensure_ascii=True — значение по умолчанию. Файл валиден и прочитается правильно, но нечитаем глазами. Лечится ensure_ascii=False плюс encoding="utf-8" при записи.
Как сохранить datetime в JSON
Через default=str для быстрого случая или преобразованием к isoformat() перед сериализацией, если формат важен. Обратно даты сами не восстановятся — разбирать их из строк придётся своим кодом.
Почему число, которое было ключом, стало строкой
Потому что в JSON ключи объекта бывают только строками. json.dumps({1: "a"}) даёт {"1": "a"}, и обратное чтение вернёт ключ '1', а не 1. Если ключи должны остаться числами, приводи их обратно после разбора или храни данные списком объектов вместо словаря.
Как получить JSON из HTTP-ответа
У requests есть метод response.json() — это json.loads(response.text) с той же семантикой и теми же исключениями. Если тело пустое или сервер вернул HTML вместо JSON, будет JSONDecodeError: Expecting value: line 1 column 1 (char 0). Поэтому сначала проверяют код ответа, и только потом разбирают тело.
Где потренироваться
Все блоки в этой статье запускаются прямо на странице — поменяй данные и посмотри, что изменится в выводе. На живых задачах JSON встречается там, где надо принять данные и привести их к нужному виду: проверка пароля и дедупликация с сохранением порядка как раз про такую обработку.