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 формально допустимы).
Шесть типов покрывают всё. Никаких дат, бинарных данных, complex чисел
Чего в 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 по умолчанию — да), но это нарушение стандарта.
Если ваш 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
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 encodersStdlib json написан на чистом Python (CPython с C-расширениями для horseshoe path, но многое — Python). Это медленно для больших файлов.
# 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:
- 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}")