Learning Platform
Глоссарий Troubleshooting
Урок 05.01 · 22 мин
Начальный
JSONRFC 8259UnicodeorjsonNumber precision

JSON deep: что внутри, как парсить, где сломается

JSON выглядит обманчиво простым: «ну скобки, кавычки, числа, всё понятно». Но за этой простотой прячется масса нюансов, на которых регулярно спотыкаются junior-разработчики: NaN, который не валидный JSON; ID пользователя 9007199254740993, который превращается в 9007199254740992; эмодзи, который ломается в ensure_ascii=True; парсер, который тратит 5 секунд на 50 МБ файла, когда orjson справится за 200 мс.

В этом уроке разберём, что говорит RFC 8259 о JSON, как Python типы маппятся на JSON-типы, где теряется точность, как ускорить парсинг и когда JSON — неправильный выбор.


RFC 8259: формальная спецификация

Современный JSON определён в RFC 8259 (2017). До него были RFC 4627 (2006) и RFC 7159 (2014) — каждый исправлял неоднозначности. Сейчас RFC 8259 — это «истина», и большинство парсеров ему следуют.

Формально JSON — это:

  • Корневое значение: одно из шести типов (object, array, string, number, true/false, null).
  • Структурные символы: { } [ ] : ,.
  • Whitespace между токенами: пробел, tab, CR, LF — игнорируется.
  • UTF-8 как кодировка по умолчанию (хотя UTF-16 и UTF-32 формально допустимы).
Типы JSON

Шесть типов покрывают всё. Никаких дат, бинарных данных, complex чисел

objectНабор пар ключ-значение в фигурных скобках. Ключ всегда string. Порядок ключей по спецификации не значим, но большинство парсеров сохраняют исходный порядок
arrayУпорядоченный список значений. Элементы могут быть разных типов
stringUTF-8 текст в двойных кавычках. Поддерживает escape-последовательности и \\uXXXX для Unicode code points
numberДесятичные числа, опционально с дробной частью и экспонентой. RFC не определяет точность -- это проблема, см. ниже
true / falseБулевые литералы строчными буквами. True (с большой) -- синтаксическая ошибка
nullОтсутствие значения. Не путать с undefined в JSON -- undefined нет, null -- единственный 'пустой' литерал

Чего в JSON НЕТ:

  • Даты и времени — нужно представлять строкой (обычно ISO 8601: "2026-05-15T12:30:00Z").
  • Бинарных данных — нужно кодировать в base64 (раздувает размер на 33%).
  • Комментариев — JSON-парсер упадёт на // comment. Существует JSON5 и JSONC, но это не стандарт.
  • Trailing comma[1, 2, 3,] — синтаксическая ошибка по RFC 8259.
  • NaN, Infinity, -Infinity — невалидны. Многие парсеры их принимают (Python json по умолчанию — да), но это нарушение стандарта.
WARNING

Если ваш Python-словарь содержит float('nan') или float('inf'), json.dumps() по умолчанию выведет NaN или Infinity без кавычек — это валидный Python, но НЕ валидный JSON. Другие системы (особенно JS, Java) не смогут это распарсить. Используйте json.dumps(data, allow_nan=False) чтобы получить exception при попытке сериализации, или сами проверяйте/маскируйте такие значения.


Strings: Unicode, эскейпы, ensure_ascii

JSON-string — это последовательность Unicode code points в двойных кавычках. Внутри string есть escape-последовательности:

\"   двойная кавычка
\\   backslash
\/   forward slash (опционально)
\b \f \n \r \t   управляющие символы
\uXXXX   Unicode code point в hex (4 цифры)

Для code points выше U+FFFF используется UTF-16 surrogate pair — два 16-битных слова, например U+1F600 кодируется как пара (emoji U+1F600).

Python json.dumps() по умолчанию выставляет ensure_ascii=True, что эскейпит весь не-ASCII через \uXXXX. Это удобно для совместимости (выходной файл — чистый ASCII), но раздувает размер и затрудняет чтение глазом.

import json

# Используем Python escape для code point U+1F600
data = {"name": "Алиса", "char": "\U0001F600"}

# По умолчанию -- все не-ASCII через escape
print(json.dumps(data))
# {"name": "Алиса", "char": "(emoji U+1F600)"}

# С ensure_ascii=False -- UTF-8 как есть (surrogate pair собирается обратно)
print(json.dumps(data, ensure_ascii=False))
# {"name": "Алиса", "char": "<символ U+1F600>"}

# С indent -- pretty-print
print(json.dumps(data, ensure_ascii=False, indent=2))
# {
#   "name": "Алиса",
#   "char": "<символ U+1F600>"
# }

Когда выбирать ensure_ascii=False:

  • Файл предназначен для чтения людьми.
  • Размер файла важен (UTF-8 экономнее, чем \uXXXX).
  • Получатель умеет UTF-8 (99% современных систем).

Когда оставить ensure_ascii=True:

  • Транспорт может разрушить multibyte (legacy ASCII-only протоколы).
  • Сериализуем в C-строки или другие форматы, где non-ASCII опасен.

Numbers: главная боль

RFC 8259 определяет number как десятичное число с опциональной дробной частью и экспонентой. Точность не оговорена. Это ключевое: каждый парсер сам решает, как представлять число.

Большинство языков представляют JSON-number через 64-bit IEEE 754 double-precision float. Это даёт безопасный диапазон целых чисел только до 2^53 - 1 = 9 007 199 254 740 992. Числа больше этого — теряют точность.

Потеря точности у больших целых

JS / любой парсер с float64 теряет low-order биты у целых чисел больше 2^53

90071992547409922^53 -- максимальное безопасно представимое целое в IEEE 754 double. До этого числа -- точно. После -- каждое второе число становится 'неотличимым' от соседнего
90071992547409932^53 + 1 -- НЕ представимо точно в double. Преобразуется в ближайшее: 9007199254740992. Тихая потеря точности, никаких ошибок
Twitter snowflake IDTwitter (X) использует 64-bit IDs (snowflakes). Они больше 2^53. Если API отдаёт ID как number -- JS-клиент потеряет точность. Twitter поэтому отдаёт ID и как number, и как string ('id_str')
Решение: string для больших IDAPI дисциплина: всегда передавать ID >= 2^53 как string, не как number. На сервере (Python) число точное, на клиенте (JS) -- превратится в string и не сломается

Python-нюанс: json.loads() по умолчанию читает числа как int или float. Для целого числа любой длины (Python int — bignum) — парсинг точный. Но если число пришло как string (например, ID в Twitter) — оно остаётся string, и вам нужно конвертировать вручную.

import json

# Парсинг big int -- Python int спасает
s = '{"id": 9007199254740993}'
print(json.loads(s))  # {'id': 9007199254740993} -- точно

# Сериализация -- тоже точно
print(json.dumps({"id": 9007199254740993}))  # '{"id": 9007199254740993}'

# Но если этот JSON парсит JavaScript (или Java без BigInteger):
# JSON.parse('{"id": 9007199254740993}')
# -> {id: 9007199254740992}   // потеряли точность!

Дробные числа — отдельная история. 0.1 + 0.2 == 0.3 ложно в любом языке с IEEE 754 (включая Python, JS, Java). JSON это не решает — он передаёт float как есть, а получатель сам декодирует. Для денег и других точных вычислений используйте Decimal:

import json
from decimal import Decimal

# Парсинг с Decimal вместо float
data = json.loads('{"price": 19.99}', parse_float=Decimal)
print(type(data['price']))  # <class 'decimal.Decimal'>
print(data['price'] * 3)    # Decimal('59.97') -- точно

# Без parse_float:
data = json.loads('{"price": 19.99}')
print(data['price'] * 3)    # 59.96999999999999 -- float drift

Парсинг в Python: stdlib json

Стандартная библиотека Python — json. Базовые операции:

import json

# Object -> string
json_str = json.dumps({"a": 1, "b": [1, 2, 3]})
# Pretty + custom separators
pretty = json.dumps(data, indent=2, separators=(',', ': '), sort_keys=True)

# String -> object
obj = json.loads('{"a": 1}')

# Файл
with open("data.json", "w") as f:
    json.dump(data, f, indent=2)

with open("data.json") as f:
    data = json.load(f)

Полезные параметры dumps:

  • indent=2 — pretty-print с указанным indent.
  • sort_keys=True — стабильный порядок ключей. Важно для diff’ов и tests.
  • ensure_ascii=False — UTF-8 без эскейпа.
  • separators=(',', ':') — компактный вывод (без пробелов после запятой и двоеточия).
  • default=callable — функция для сериализации не-JSON типов (datetime, Decimal, custom classes).

Пример с default:

from datetime import datetime, date
import json

def encoder(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError(f"Type {type(obj)} not serializable")

data = {"created_at": datetime.now()}
print(json.dumps(data, default=encoder))
# {"created_at": "2026-05-15T12:30:00.123456"}

Performance: orjson vs ujson vs stdlib

JSON и JSONL: формат с типами и для потоков JSON: json.loads, json.dumps, JSONL streaming, custom encoders

Stdlib json написан на чистом Python (CPython с C-расширениями для horseshoe path, но многое — Python). Это медленно для больших файлов.

Скорость JSON-парсеров (примерно, на 100 МБ файле)
json (stdlib)Базовая библиотека Python. Чистый Python с C-вкраплениями. ~2 секунды на 100 МБ парсинг
ujsonUltraJSON -- C-библиотека. Быстрее stdlib в 2-3 раза. Менее строгий по спецификации (терпимее к нарушениям)
orjsonRust-библиотека. Самая быстрая. Парсинг 5-10x быстрее stdlib, сериализация 5-15x. Возвращает bytes из dumps (нюанс!), нативно поддерживает datetime, UUID, dataclass, numpy
# pip install orjson
import orjson

# Парсинг -- принимает str или bytes
data = orjson.loads(json_bytes)

# Сериализация -- возвращает BYTES, не str (важно!)
serialized = orjson.dumps(data)
# Если нужен str: serialized.decode('utf-8')

# Опции через флаги
serialized = orjson.dumps(
    data,
    option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS | orjson.OPT_NAIVE_UTC,
)

# Нативная поддержка datetime, UUID, dataclass
from datetime import datetime
from uuid import uuid4
print(orjson.dumps({"id": uuid4(), "ts": datetime.now()}))
# stdlib json упал бы -- orjson сам сериализует

Когда выбирать orjson:

  • Большие файлы (>10 МБ).
  • Высокая нагрузка (тысячи запросов в секунду).
  • Нужна нативная поддержка datetime/UUID/dataclass.

Минусы:

  • Сериализация возвращает bytes, не str — небольшое неудобство.
  • Не настолько строгий drop-in (некоторые опции отличаются от stdlib).
  • Бинарная зависимость (orjson — Rust + Python wheel).

Когда стоит остаться на stdlib:

  • Маленькие данные (< 1 МБ).
  • Не хочется внешних зависимостей.
  • Используете редкие опции default/object_hook со сложной логикой.

Когда JSON не подходит

Avro Deep-Dive: бинарный формат с эволюцией схемы

JSON хорош для текстовых, schema-less, относительно небольших данных. Он плох когда:

JSON vs альтернативы
Бинарные данныеJSON-strings -- UTF-8. Бинарные данные приходится кодировать в base64, что раздувает на 33%. Для больших файлов (изображения, PDF) -- катастрофа
Решение: MessagePack, Avro, Protobuf, ParquetБинарные форматы передают bytes как bytes. MessagePack -- JSON-compatible, но binary. Avro/Protobuf -- строгие схемы, ещё компактнее. Parquet -- для аналитики
Большие табличные данныеJSON repeats keys для каждого row: [{'name': 'A', 'age': 1}, {'name': 'B', 'age': 2}, ...] -- keys 'name' и 'age' хранятся 1000 раз для 1000 rows. Накладные расходы на парсинг тоже большие
Решение: CSV, ParquetCSV: keys один раз в header. Parquet: column-based + compression, идеален для analytics
Streaming / large filesОдин большой JSON object/array требует полностью загрузиться в память для парсинга. Файл 10 ГБ JSON -- 20+ ГБ RAM пиково (объект + строка + временные структуры)
Решение: JSONL/NDJSONОдна JSON-запись на строку. Можно читать и обрабатывать по одной, не загружая в память все. Стандарт de-facto для логов и event-streams
Strict schema, speedJSON -- schema-less. Контракт в комментариях / документации. Парсер не валидирует структуру, ошибка выявится в runtime. Нет компактного binary representation
Решение: Avro, ProtobufSchema first. Парсер валидирует. Binary representation -- компактно. Для inter-service communication часто лучше JSON

Хороший кейс для JSON:

  • REST API responses / requests.
  • Конфиги (но YAML тоже хорош).
  • Logs (JSONL формат).
  • Web frontend communication (нативный для JS).

Плохой кейс для JSON:

  • Передача 100 МБ изображения.
  • Таблица из 10 миллионов строк.
  • High-frequency trading с микросекундными требованиями.
  • Inter-service RPC с строгой схемой.

Подводные камни

Дубликаты ключей в object. RFC 8259 говорит: «names within object should be unique», но не запрещает дубликаты. Поведение парсера не определено. Большинство (json, orjson) берут последнее значение. Не полагайтесь на это — генерируйте JSON без дубликатов.

import json
# {"a": 1, "a": 2}  -> {"a": 2}  в Python
# Но в другом языке поведение может быть другим

Глубокая вложенность -> stack overflow. Парсеры рекурсивны. JSON со вложенностью 100 000 — вылет с RecursionError. Python json по умолчанию ставит лимит. orjson — настраивается через OPT_PASSTHROUGH_DICT.

Surrogate pairs в Python. Python 3 строки — Unicode. JSON-парсер корректно собирает surrogate pair (emoji U+1F600) обратно в один code point U+1F600. Но если вы вручную манипулируете строкой (regex, slicing) — можно случайно разорвать пару.

Большие float’ы. json.dumps({"x": 1e308}) работает. 1e310 уже инфинити, и allow_nan=False упадёт. Аккуратно с экстремальными значениями.

Trailing newline. Многие генераторы добавляют \n в конец файла. По стандарту — допустимо (whitespace игнорируется). Но JSON parser в строгом режиме на двух JSON-объектах подряд ({"a":1}\n{"b":2}) упадёт. Это не валидный JSON — это JSONL.


Попробуй сам

import json
import orjson
from decimal import Decimal
from datetime import datetime
import time

# 1. Сравнить ensure_ascii
data = {"name": "Алиса", "city": "Москва"}
print("With ensure_ascii=True:", json.dumps(data))
print("With ensure_ascii=False:", json.dumps(data, ensure_ascii=False))

# 2. Big int -- где сломается?
big = {"id": 9007199254740993, "small": 42}
print("Python json:", json.dumps(big))
# JS: JSON.parse(...) -> id: 9007199254740992 (потеряно!)
# Workaround: id как string

# 3. Decimal для денег
data = json.loads('{"price": 0.1, "tax": 0.2}', parse_float=Decimal)
print("Total:", data['price'] + data['tax'])  # Decimal('0.3') -- точно
data2 = json.loads('{"price": 0.1, "tax": 0.2}')
print("Float total:", data2['price'] + data2['tax'])  # 0.30000000000000004

# 4. Performance -- сравним на большом массиве
big_list = [{"id": i, "value": "x" * 100} for i in range(100_000)]

start = time.perf_counter()
s = json.dumps(big_list)
print(f"json dumps: {time.perf_counter() - start:.3f}s, size={len(s)}")

start = time.perf_counter()
b = orjson.dumps(big_list)
print(f"orjson dumps: {time.perf_counter() - start:.3f}s, size={len(b)}")

# 5. Что не валидно по JSON RFC?
samples = [
    "{'a': 1}",          # single quotes
    '{"a": 1,}',          # trailing comma
    '{"a": NaN}',         # NaN
    "{a: 1}",             # unquoted key
    "// comment\n{}",     # comment
]
for s in samples:
    try:
        json.loads(s)
        print(f"OK:    {s!r}")
    except json.JSONDecodeError as e:
        print(f"ERR:   {s!r}  -> {e.msg}")

Проверка знанийKnowledge check
Junior пишет API на FastAPI, который возвращает {"id": user.id, "balance": account.balance}. user.id -- UUID (36-символьная строка), account.balance -- Decimal. JS-frontend читает: id отображается правильно, а balance показывает 19.989999999999998 вместо 19.99. Junior спрашивает: где баг и как чинить? Дай полный анализ с альтернативами.
ОтветAnswer
Баг -- на стыке Decimal -> JSON-number -> JavaScript float. Цепочка: (1) В Python account.balance = Decimal('19.99') -- точное представление. (2) FastAPI/Pydantic при сериализации в JSON конвертирует Decimal -> float (потому что Decimal не валидный JSON-тип) -- здесь уже теряется точность: float64 не может точно представить 19.99 (получается 19.989999999999998 или 19.99 в зависимости от способа округления при выводе). (3) JSON передаёт это число как text. (4) JavaScript JSON.parse читает в Number (тоже float64) -- видит 19.989999999999998. Баг не во frontend, а в выборе типа для денег. Решения: (1) Лучшее: возвращать balance как STRING -- '19.99'. JS-клиент читает строку, использует для отображения как есть, для математики использует библиотеку BigDecimal/decimal.js. Контракт: в OpenAPI указать type: string format: decimal. (2) Использовать минорные единицы (cents) как integer -- balance_cents: 1999. JS превратит обратно делением. Безопасно до 2^53 центов = $90 триллионов. (3) Если нужен number в JSON -- использовать orjson с OPT_PASSTHROUGH_DECIMAL и кастомным default, который сериализует Decimal как-то особенно (но это нестандартно). Для финансовых API правило: НИКОГДА не передавать деньги как JSON-number с float64. Используйте string или integer minor units. Это требование PCI DSS и ISO 20022 для платежных систем.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 5. Python код: json.dumps({'value': 9007199254740993}). На сервере результат правильный, но JS-frontend (JSON.parse) показывает 9007199254740992. Где теряется точность?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5