JSON в Python: dumps и loads, файлы и разбор ошибок

Python Автор: Среда и версия: CPython 3.14.7; исполняемые блоки — Pyodide 3.14.0 прямо на странице
содержание

JSON — это текст. Не структура данных в памяти, не объект, а строка по строгим правилам: двойные кавычки, никаких висящих запятых, конечный набор типов. Модуль json занимается ровно переводом между этим текстом и обычными объектами Python.

Функций для этого четыре, и почти вся путаница живёт в одной букве:

Python / 01

Четыре функции — две пары направлений

Четыре функции — две пары направлений01 / вход 02 / операция 03 / результат объект → строка json.dumps(obj) str строка → объект json.loads(text) dict / list / … объект → файл json.dump(obj, f) запись в f файл → объект json.load(f) dict / list / …01 / вход02 / операция03 / результатобъект → строкаjson.dumps(obj)strстрока → объектjson.loads(text)dict / list / …объект → файлjson.dump(obj, f)запись в fфайл → объектjson.load(f)dict / list / …
Буква 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 / Falsetrue / falsebool
NonenullNone
ключ-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]}
Python / 02

Возврат из JSON не восстанавливает все типы Python

Возврат из JSON не восстанавливает все типы Python01 / вход 02 / операция 03 / результат ключ: int dumps → loads ключ: str tuple dumps → loads list01 / вход02 / операция03 / результатключ: intdumps → loadsключ: strtupledumps → loadslist
Ни исключения, ни предупреждения: 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 встречается там, где надо принять данные и привести их к нужному виду: проверка пароля и дедупликация с сохранением порядка как раз про такую обработку.

Источники