JSON Toolkit

Отладка минифицированного JSON: от строки 1, столбца 23611 до точного сломанного байта

Главное: Минифицированный JSON-файл на 4 МБ в одну строку сообщает «строка 1, столбец 23611», что ничего не говорит. На локально измеренных примерах разбираем, почему Python, Node и jq дают три разных числа, как смещение символа приводит прямо к сломанному байту и какие три ловушки тихо портят данные после исправления синтаксиса.

Продвинутый~7 мин чтенияОпубликовано 2026-11-01Обновлено 2026-11-01
Обложка статьи о поиске ошибок в минифицированном JSON

Коротко: номера строк и столбцов что-то значат только в многострочных документах. Как только JSON сжат в одну строку, все ошибки живут в строке 1 — нужное вам число это не строка, а индекс символа.

Минифицированный JSON-документ содержит ровно одну строку, поэтому пара «строка/столбец» в сообщении об ошибке сжимает позицию в четыре миллиона байт до одной бесполезной фразы. Обходной путь — полностью отказаться от строки/столбца и читать смещение символа: скромное (char 23610) в конце сообщения и есть число, которое реально вас спасает.

Все числа ниже получены локально. Образец — однострочный документ с 13 000 событий выставления счетов, всего 2 011 084 символа, и одна запятая намеренно удалена после event_id события №154. Окружение — Python 3.11.1, Node 22.13.1 и jq 1.7.

1. Сначала факты: три парсера, три числа

Один и тот же сломанный файл, переданный трём инструментам:

Python 3.11.1  Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node  22.13.1  Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq    1.7      Expected separator between values at line 1, column 23619     exit code 5

Рядом друг с другом:

Инструмент Сообщаемое значение Единица Откуда считается Почему строка/столбец не работает
Python JSONDecodeError.pos 23610 Смещение символа Начало буфера Единственное, чему можно верить
Python lineno / colno 1 / 23611 Строка и столбец Начало строки Строка всего одна, так что это ничего не говорит
Node position 23610 Смещение символа Начало буфера Надёжно, хотя сообщение скрывает поле
jq column 23619 Смещение символа Начало проблемного токена Отличается от Python на 9

Вывод прост: char N в Python и position N в Node — одно и то же число, они совпадают. jq использует собственную схему — не тратьте время на её согласование.

Три числа различаются не потому, что кто-то ошибся в расчётах, а потому, что они считают разные вещи. Python и Node сообщают, где парсер остановился; jq сообщает, где начинается токен, который не удалось разобрать. Между этими точками лежит несколько уже использованных символов — в этом образце 9.

2. Почему «строка 1, столбец N» на таком файле — шум

Номера строк — производная величина, которая предполагает «с последнего перевода строки прошло много символов». У минифицированного документа один \n, обычно в конце, поэтому:

  • lineno всегда 1;
  • colno вырождается в «сколько символов от начала этой единственной строки»;
  • а эта строка шириной в четыре миллиона символов.

Перейдите к столбцу 23611 в редакторе и не увидите ничего полезного. Хуже того, инструменты вроде less и vim еле ползут по чрезвычайно длинным строкам; двигать курсор стрелками — это двадцать минут.

Переверните картинку: смещение — это исходная величина, а строка/столбец вычисляются из неё. Используйте исходную величину.

3. Подход первый: в Python берите e.pos и игнорируйте lineno

JSONDecodeError несёт три числа, и только pos стоит читать. lineno и colno — производные значения, которые на однострочном документе схлопываются в ничто.

# locate.py - Python 3.11.1
import json
import sys

path = sys.argv[1]
raw = open(path, encoding='utf-8').read()

try:
    json.loads(raw)
except json.JSONDecodeError as error:
    print(f'{type(error).__module__}.{type(error).__name__}: {error}')
    start = max(0, error.pos - 60)
    end = min(len(raw), error.pos + 60)
    print(f'\nchar {start}..{end} of {len(raw)}:')
    print(raw[start:end])
    print(' ' * (error.pos - start) + '^')
    sys.exit(1)

print('valid JSON')

Запустите его на сломанном файле:

$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)

char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
                                                            ^

Каретка отмечает место, где пропущена запятая. Двадцать минут превращаются в одну команду.

Насколько широким должно быть окно: ±60 символов почти всегда охватывают полную пару ключ/значение. Слишком узко — теряете контекст; слишком широко — вывод заполняет экран. Для глубоко вложенных документов можно перейти к ±120.

4. Подход второй: в контейнере нет Python — jq плюс cut

В продакшн-контейнерах часто нет Python, но почти всегда есть jq — или его можно получить одной командой apt-get install jq. jq даёт громкий отказ:

$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5

Код выхода 5 означает неверный ввод, поэтому под set -e он прерывает скрипт так же, как grep, вернувший 1. Это фича.

У jq нет флага для вывода проблемных байтов, поэтому передайте позицию cut:

# Note -c (characters), not -b (bytes)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",

Когда запятая возвращена, jq может делать то, в чём он действительно хорош:

$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479

5. Подход третий: нет рантайма или данные не должны касаться диска — вставьте в браузер

Первые два подхода требуют установленного инструмента и права записи файлов. Если у вас голый ноутбук или в полезной нагрузке полно клиентских email и IP-адресов, а фраза «я вставил это на какой-то сайт» — не то, что хочется видеть в отчёте об инциденте, подходит третий вариант.

Вставьте всю строку в онлайн-парсер JSON, и он поставит каретку на проблемный символ и обозначит строку и столбец; преимущество локального инструмента в том, что эти 4 МБ никогда не покидают машину и не попадают в чьи-то логи.

Две оговорки:

  1. Выбирайте инструмент, который явно говорит, что обрабатывает всё в браузере, а не тот, что отправляет содержимое на бэкенд POST-запросом;
  2. Вставка терпит очень большие файлы, но выше нескольких десятков мегабайт уже сама вкладка съедает память — в этот момент возвращайтесь к первому подходу.

6. Смещение символа — не смещение байта: вторая ловушка на CJK-данных

Эта ловушка кусает оба подхода на смешанных китайско-английских нагрузках.

JSONDecodeError.pos — это смещение символа (счёт кодовых точек Unicode), потому что получается при разборе str. cut -c режет по символам и совпадает с ним. Но head -c, tail -c и dd режут по байтам, и под UTF-8 одна ошибка сдвигает всё.

Китайский образец делает это наглядным (5 заказов, 148 символов, 222 байта):

>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35]                                    # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # bytes 25-35
b'DD00000","'

То же смещение: символьное представление попадает на имя поля, байтовое — на хвост идентификатора заказа. Один CJK-символ занимает 3 байта в UTF-8, и дрейф накапливается с позицией.

Правило: прочитав e.pos, режьте через cut -c или срез Python, никогда не через head -c / dd bs=1 skip=N. Если у вас есть только байтовые инструменты, сначала конвертируйте: len(raw[:error.pos].encode('utf-8')).

7. Синтаксис исправлен, данные всё ещё неверны: три тихие ловушки

Именно поэтому отладка не может останавливаться на «оно разобралось». Каждая из перечисленных ниже ловушек либо никогда не выдаёт ошибку, либо выдаёт её спустя долгое время после использования значения.

Ловушка 1: повторяющиеся ключи — побеждает последний, никто не жалуется

Раздел 4 RFC 8259 ясен: имена внутри объекта ДОЛЖНЫ быть уникальными, а когда они не уникальны, поведение непредсказуемо — большинство реализаций сохраняют последнее вхождение, другие выдают ошибку. Все три измеренных здесь инструмента сохраняют последнее:

Input   {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node    {"order_id":"A-1002","total":4200}
Python  {'order_id': 'A-1002', 'total': 4200}
jq      {"order_id":"A-1002","total":4200}

Ни единого предупреждения. Если перезаписанное поле было amount_cents, вы узнаете об этом, когда сверка расходится на три цента.

Ловушка 2: NaN и Infinity — Python их принимает, Node нет

Раздел 6 RFC 8259 прямо говорит, что числовые литералы не содержат NaN и Infinity. json.loads в Python принимает оба по умолчанию, потому что собственный float Python имеет такие значения:

>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}'    # written straight back out

Передайте этот вывод Node, и получите:

SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON

Иными словами: плохие данные проходят Python и взрываются у потребителя ниже по потоку.

Ловушка 3: целые числа за пределами 2^53 тихо теряют точность

Один документ, два разных значения:

JSON source   {"order_no":12345678901234567890}
Python        12345678901234567890          <- kept as-is
Node          12345678901234567000          <- last three digits flattened

Причина в одной строке: Number.MAX_SAFE_INTEGER в JavaScript равен 9007199254740991 (2^53-1). MDN особо отмечает, что reviver выполняется после того, как значение уже разобрано в число JavaScript, поэтому точность теряется до reviver — её нельзя восстановить на стороне десериализации. Единственное исправление — производитель должен сериализовать большие идентификаторы как строки.

Сравнение трёх ловушек:

Ловушка Что-то выдаёт ошибку? Кто замечает первым Как перехватить
Повторяющиеся ключи Нет Сверка ниже по потоку Явная проверка через object_pairs_hook
NaN / Infinity Node выдаёт ошибку, Python молчит Потребители ниже по потоку Отклонять на границе через parse_constant
Целые числа за 2^53 Нет Код, выполняющий проверки равенства Передавать их строками

Поймать первые две за один проход

# check.py - Python 3.11.1
import json
import sys
from collections import Counter


def no_dupes(pairs):
    counts = Counter(key for key, _ in pairs)
    dupes = sorted(key for key, n in counts.items() if n > 1)
    if dupes:
        raise ValueError(f'duplicate key(s): {dupes}')
    return dict(pairs)


def reject_constant(name):
    raise ValueError(f'non-standard JSON constant: {name}')


raw = open(sys.argv[1], encoding='utf-8').read()
try:
    data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
    start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
    print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
    print(raw[start:end])
    print(' ' * (error.pos - start) + '^')
    sys.exit(1)
except ValueError as error:
    print(f'rejected: {error}')
    sys.exit(1)

print(f'valid JSON, {len(raw)} chars')

Цена, если честно: на том же образце в 2 011 084 символа строгая версия укладывается в 35,2–36,2 мс против 9,4–9,9 мс у мягкой, примерно в 3,7 раза медленнее (от 3,6 до 3,8 раза по пяти раундам по пять прогонов, медиана по раунду) — object_pairs_hook запускает Counter для каждого объекта. Держите её вне «счастливого пути» CI и переключайтесь на неё только когда поставка выглядит подозрительно.

8. Контрольный список

После исправления файла соблазнительно отформатировать его для читаемости, но тот же образец занимает 2 011 084 символа в минифицированном виде (separators=(',',':')) и 2 869 100 символов при indent=2 — на 43% больше. Этот вес уходит в историю Git, кэши CI и сетевую передачу, поэтому держите красивую версию локально и сожмите обратно в одну строку перед выпуском.

  • [ ] Сначала ищите (char N) / position N; игнорируйте line 1 column N;
  • [ ] В Python используйте e.pos, jq сочетайте с cut -c — это одни и те же смещения символов, поэтому никогда не согласовывайте column из jq с ними;
  • [ ] Для CJK-документов режьте по символам (cut -c, срезы Python), никогда head -c / dd;
  • [ ] Скрипты в контейнерах должны трактовать код выхода jq 5 как «неверный ввод»;
  • [ ] Перепроверяйте подозрительные данные через object_pairs_hook + parse_constant и принимайте цену примерно в 3,7 раза;
  • [ ] Передавайте любой ID выше 2^53 строкой; не ждите, что reviver его спасёт;
  • [ ] Вывод с красивым форматированием (измерено +43%) остаётся локальным: используйте /minify, чтобы сжать обратно в одну строку перед выпуском или коммитом; выше нескольких МБ переключайтесь на потоковую обработку, о ней — в статье Разбор, рендеринг и потоковая обработка больших JSON.

FAQ

Почему все три инструмента сообщают разную позицию?

Потому что они считают разные вещи. char N в Python и position N в Node означают «где парсер остановился» — на этом образце оба равны 23610, так что совпадают. jq сообщает, где начинается проблемный токен, здесь 23619, на девять дальше. Никто не ошибся в расчётах, и никакая поправка не нужна; сравнивайте числа только внутри одного инструмента.

Использовать cut -c или head -c?

Используйте cut -c. JSONDecodeError.pos — смещение, считаемое в символах Unicode, а cut -c режет по символам, поэтому они согласуются. head -c, tail -c и dd режут по байтам, что даёт дрейф на UTF-8-документах с CJK-текстом и может даже разрезать символ пополам. На китайском образце то же смещение даёт 城市":"杭州"}, в символьном представлении и DD00000"," в байтовом.

Обработка повторяющихся ключей действительно везде не защищена?

Да. Раздел 4 RFC 8259 лишь говорит, что имена ДОЛЖНЫ быть уникальными, и утверждает, что поведение при их неуникальности непредсказуемо. Python, Node и jq — все измеренные здесь — применяют «побеждает последний», не выдавая никакого предупреждения. Чтобы это поймать, нужен собственный object_pairs_hook, ценой примерно в 3,7 раза больше времени разбора.

Почему Python читает NaN нормально, а Node бросает ошибку?

Раздел 6 RFC 8259 запрещает NaN и Infinity как числовые литералы. json.loads в Python принимает их по умолчанию, потому что собственный float Python имеет такие значения, поэтому плохие данные проходят Python и сразу записываются обратно. JSON.parse в Node строго следует спецификации и бросает SyntaxError на таком выводе. Добавление parse_constant на входе отклоняет их немедленно.

20-значные номера заказов всегда теряют точность в JSON?

Только когда потребитель разбирает их в double IEEE 754. int в Python произвольной точности и сохраняет 12345678901234567890; Number.MAX_SAFE_INTEGER в JavaScript равен 9007199254740991, поэтому то же поле становится 12345678901234567000. MDN особо отмечает, что reviver выполняется после разбора, так что ниже по потоку ничего не восстановить — единственное надёжное исправление в том, чтобы производитель выдавал строки.