Один и тот же JSON — четыре инструмента, четыре результата: как выбрать форматтер
Главное: Форматирование не бывает без потерь. Один и тот же JSON с ведущим нулём, BOM или 20-значным целым по-разному обрабатывают Python, Node, jq и json.tool: одни сообщают об ошибке, другие молча меняют значение. Десять собственных тестов на четырёх путях дают таблицу выбора и готовый скрипт строгой проверки.

Вывод: главное различие между JSON-форматтерами не в том, сколько пробелов они ставят, а в том, что один и тот же вход где-то вызывает ошибку, а где-то молча переписывает данные. Один образец с граничными значениями даёт больше, чем любой список функций.
Форматирование часто считают «просто перестановкой текста». На деле каждый путь заново интерпретирует его своим парсером. Если парсер снисходителен, ошибка, которая должна была появиться, превращается в тихую подмену значения, а страница по-прежнему показывает успокаивающее зелёное «Форматирование выполнено».
1. Факт: один вход, четыре пути, четыре результата
Тест на Python 3.11.1, Node v22.13.1 и jq 1.7. Десять коротких JSON-строк, собранных вручную, подавались через стандартный ввод без записи на диск и без смены настроек. Пути: json.loads, JSON.parse, jq -c ., python -m json.tool. ERR — исключение и остановка; SILENT — ошибки нет, но вывод отличается от входа.
| Образец | python json.loads | node JSON.parse | jq -c . | python -m json.tool |
|---|---|---|---|---|
S1 {"a":1,"b":[1,2]} |
OK | OK | OK | OK |
S2 {"a":1,} |
ERR | ERR | ERR | ERR |
S3 {'a':1} |
ERR | ERR | ERR | ERR |
S4 {"a":01} |
ERR char 6 | ERR position 6 | SILENT {"a":1} |
ERR |
S5 {"a":1 /*x*/} |
ERR char 7 | ERR | ERR | ERR |
S6 \ufeff{"a":1} |
ERR utf-8-sig | ERR position 0 | SILENT {"a":1} |
ERR |
S7 {"id":9007199254740993} |
OK точно | SILENT 9007199254740992 | OK точно | OK |
S8 {"a":1,"a":2} |
SILENT {"a":2} |
SILENT {"a":2} |
SILENT {"a":2} |
OK |
S9 {"a":1 |
ERR char 6 | ERR | ERR EOF | ERR |
S10 <html>502</html> |
ERR char 0 | ERR Unexpected token '<' | ERR | ERR |
Четыре образца из десяти дали «без ошибки, но с изменёнными данными»: S4, S6, S7, S8. Если верить только зелёному индикатору, есть сорок процентов шанса держать не исходные данные. Хуже всего, что ни один из этих случаев не попадает в список синтаксических ошибок: файл открывается, копируется, экспортируется. Дефект в содержимом, а не в синтаксисе.
2. Причина: синтаксис и семантика — разные слои
RFC 8259 говорит прямо: раздел 1.2 рекомендует ради совместимости избегать практик, которые разрешает ECMA-404; раздел 4 требует уникальности имён лишь как SHOULD, не определяя обращение с повторными ключами; раздел 6 даёт по числам только рекомендации совместимости. ECMA-404 идёт дальше и объявляет себя «независимой от семантики чисел»: регламентируется представление, а не хранение. То есть спецификация сознательно оставляет широкую пограничную зону реализаторам.
Поэтому «форматирование» распадается на два слоя: синтаксический решает, легален ли текст, семантический — каким значением он станет. У первого есть стандартный ответ, у второго нет. jq, читая 01 как 1 и пропуская начальный BOM, делает семантический выбор; Node, втискивая 20-значное целое в double IEEE 754, — выбор среды исполнения; все четыре пути, оставляя последнее значение {"a":1,"a":2}, — общая договорённость парсеров. Ничто не нарушает «можно разобрать», но всё меняет данные.
Как исправить ошибки разбора JSON работает на синтаксическом слое и переводит текст ошибки в координаты. Эта статья — о шаге до него: если инструмент вообще не выдал ошибку, координат не получить.
3. Четыре тихих переписывания по отдельности
Вынесенные отдельно, четыре образца показывают изменения, слишком малые, чтобы заметить их на глаз:
{"a": 01} // нуль в начале: jq выдаёт {"a":1}
\ufeff{"a": 1} // BOM: jq пропускает первые три байта
{"id": 9007199254740993} // 20-значное целое: JSON.parse выдаёт ...992
{"a": 1, "a": 2} // повтор ключа: все четыре оставляют {"a":2}
Нуль в начале и BOM — тип «вход уже был некорректен, но инструмент снисходителен». 01 незаконен в стандартном JSON (RFC 8259 §6), BOM не должен начинать JSON-текст; Python и Node отклоняют, jq продолжает. Удобно при чистке логов, губительно при проверке контракта: отправитель считает, что отправил ошибочные данные, которые надо исправить, а получатель уже держит «выглядящий нормально» результат.
Большое целое — тип «вход полностью легален, но среда не вмещает». 9007199254740993 — легальное JSON-число, но оно превышает 2^53-1, и JSON.parse может представить только 9007199254740992. int Python и jq сохраняют значение. Если это номер заказа или Snowflake ID, изменённая последняя цифра означает расхождение при сверке.
Повторные ключи — тип «спецификация не говорит, каждая реализация выбирает». Все четыре пути молча берут последнее значение без предупреждения. RFC 8259 лишь говорит SHOULD, так что это не баг, но это самый трудно находимый класс проблем при слиянии конфигов или патчей.
4. Сообщения об ошибках: кто даёт позицию, кто одну строку
Шесть неудачных образцов тоже сильно различаются объёмом информации. JSONDecodeError Python даёт msg, lineno, colno и pos; Node v22 сообщает position N (line 1 column M) с фрагментом проблемного текста; python -m json.tool использует данные Python; jq даёт лишь parse error: ... at line N без смещения символа.
В минифицированной одной строке строка и столбец почти бесполезны — всё на строке 1. Нужно смещение символа. Скрипт ниже сводит проверку к «ошибка + offset + контекст»:
import json, sys
def strict_check(text: str) -> None:
try:
json.loads(text)
print("OK")
except json.JSONDecodeError as e:
print(f"ERR {e.msg} | char {e.pos} | line {e.lineno} col {e.colno}")
lo, hi = max(0, e.pos - 30), min(len(text), e.pos + 30)
print("...", text[lo:hi].replace("\n", "\\n"), "...")
sys.exit(1)
strict_check(open(sys.argv[1], encoding="utf-8").read())
Он строже jq . (отклоняет нуль в начале и BOM) и даёт e.pos. Как перевести line 1 column 23611 в байтовое смещение и почему символьное и байтовое смещения различаются втрое в CJK-документах, описано в Отладка минифицированного JSON; здесь не повторяем.
5. Исправление: разделить «строгую проверку» и «украшательство»
Вывод прост: одна кнопка «Форматировать» не должна совмещать проверку и украшательство. Сначала строгий парсер определяет легальность, затем выбираем представление. Первый шаг выделяется одной строкой:
python3 -m json.tool input.json > /dev/null && echo "синтаксис OK" || echo "синтаксис неверен"
python3 -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); print(type(d).__name__, len(d))" input.json
Первая строка отвечает лишь «можно ли разобрать?», вторая проверяет «та ли структура, что ожидалась?». При больших целых читайте Python или jq вместо JSON.parse; при возможном BOM явно указывайте encoding='utf-8-sig' вместо надежды на автоматический пропуск; при возможных повторах ключей сканируйте отдельно до разбора, потому что ни один путь не предупредит. В CI это ровно конвейер из Редакторы, командная строка и цепочки автоматизации плюс один слой утверждений о сохранности данных.
6. Выбор: таблица четырёх путей
| Путь | Сила | Риск | Подходит для |
|---|---|---|---|
python json.loads / json.tool |
Строгий, даёт строку/столбец и смещение, целые без потери точности | Отклоняет нуль в начале и BOM, кодировку надо указать | Проверка контракта, CI, разбор логов |
node JSON.parse |
Нативный в браузере, ошибка с фрагментом источника | Большие целые тихо обрезаются, ошибка на BOM | Отладка фронтенда, браузерные инструменты |
jq -c . |
Удобен для конвейеров, самый быстрый результат | Нуль в начале и BOM проходят тихо, без смещения | Операционные конвейеры, быстрое извлечение полей |
Браузерные инструменты /parse, /editor |
Дерево, сортировка ключей, копирование пути, без установки | Поведение зависит от парсера страницы, проверьте один раз | Ручной осмотр, разовое устранение неполадок |
Вопрос не «у кого больше функций», а «солжёт ли он о моих данных?». Для номеров заказов, сумм или ID устройств выбирайте Python или инструмент с явно заявленной точностью; для чистого осмотра структуры без сохранения хватит браузера. /parse этого сайта проверяет и форматирует в браузере — хорошо для «сначала посмотреть», но большие целые перепроверьте скриптом выше.
7. Приватность — лишь одна из границ
«Загрузятся ли мои данные?» — самый частый вопрос к онлайн-инструментам, но он не зависит от «изменятся ли мои данные?». Локальный офлайн-инструмент всё равно может обрезать 20-значное целое; наоборот, онлайн-инструмент со строгими ошибками может отправить оригинал POST-ом на сервер. Два свойства проверяются отдельно, одно не заменяет другое. FAQ о приватности даёт три проверки на две минуты; эта статья добавляет вторую половину: по списку образцов ниже убедитесь, что инструмент не меняет значения молча.
8. Список самопроверки, который можно скопировать
Сохраните S1–S10 в cases.txt, по одной строке на образец, вставьте в каждого кандидата и проверьте три вещи: действительно ли падают неверные образцы, даёт ли падение конкретную позицию, совпадает ли вывод успешного образца со входом посимвольно. Особенно S4, S6, S7, S8: одна строка SILENT означает, что этот путь нельзя использовать в одиночку для проверки данных.
Добавьте три инженерных ограничения: строгую проверку — в pre-commit хук, а не обнаружение расхождения целых в продакшене; файлы писать как UTF-8 без BOM во всех языках; для JSON от третьих лиц хранить сырое тело ответа, а отформатированный результат только читать, никогда не записывать обратно.
Частые вопросы
Означает ли успешное форматирование, что JSON в порядке?
Нет. S4, S6, S7 и S8 все «прошли» форматирование, но нуль в начале, BOM, 20-значное целое и повтор ключа переписаны или проглочены. Форматирование доказывает лишь, что какой-то парсер принял текст, а не что значения равны отправленным. Проверяйте критические поля по каждому значению.
Почему jq принимает нуль в начале и BOM, которые Python отклоняет?
У jq собственный лексический анализатор: он снисходителен к числам вроде 01 и пропускает начальный BOM, тогда как Python и Node строго следуют RFC 8259. Это не баг версии, а компромисс между снисходительным и строгим парсером. Первый экономит время при чистке, второй обязателен при проверке контракта.
Как не дать большому целому стать 9007199254740992?
Number в JavaScript — это double IEEE 754 с безопасным пределом 2^53-1. Чтение 20-значного ID через JSON.parse неизбежно обрезает его. Храните такие ID строками или используйте int Python, jq либо парсер с BigInt. Если фронтенду действительно нужно число, замените это поле строкой до разбора.
Онлайн-инструмент или локальный CLI — что выбрать?
По чувствительности и цели: посмотреть структуру или найти поле — браузер быстрее всего; для конвейера и утверждений точности — python -m json.tool плюс строгий чекер. В обоих случаях сначала запустите S1–S10, сверьте ошибки и выводы, а потом решайте, какой класс данных подавать.