JSON Toolkit

Depurando JSON minificado: da linha 1 coluna 23611 ao byte quebrado exato

Resumo: Um arquivo JSON de 4 MB em uma única linha informa linha 1, coluna 23611, o que não diz nada. A partir de amostras medidas localmente, este guia explica por que Python, Node e jq relatam três números diferentes, como um deslocamento de caractere leva você direto ao byte quebrado e quais três armadilhas corrompem dados silenciosamente depois que a sintaxe é corrigida.

Avançado~7 min de leituraPublicado em 2026-11-01Atualizado em 2026-11-01
Capa sobre localização de erros em JSON minificado

Resumo da ópera: números de linha e coluna só significam algo em documentos de várias linhas. Depois que o JSON é minificado em uma única linha, todo erro vive na linha 1 — o número de que você precisa não é a linha, é o índice de caractere.

Um documento JSON minificado tem exatamente uma linha, então o par linha/coluna de uma mensagem de erro espreme uma posição de quatro milhões de bytes em uma frase inútil. A saída é ignorar linha/coluna por completo e ler o deslocamento de caractere — o humilde (char 23610) no fim da mensagem é o número que realmente salva você.

Todos os números abaixo foram produzidos localmente. A amostra é um documento de uma única linha com 13.000 eventos de cobrança, 2.011.084 caracteres no total, com uma vírgula removida de propósito após o event_id do evento nº 154. O ambiente é Python 3.11.1, Node 22.13.1 e jq 1.7.

1. Primeiro, os fatos: três analisadores, três números

O mesmo arquivo quebrado, entregue a três ferramentas:

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

Lado a lado:

Ferramenta Valor informado Unidade Contado a partir de Por que linha/coluna falha
Python JSONDecodeError.pos 23610 Deslocamento de caractere Início do buffer O único em que você pode confiar
Python lineno / colno 1 / 23611 Linha e coluna Início da linha Uma só linha, então não diz nada
Node position 23610 Deslocamento de caractere Início do buffer Confiável, embora a mensagem esconda o campo
jq column 23619 Deslocamento de caractere Início do token infrator Difere do Python em 9

A conclusão é direta: o char N do Python e o position N do Node são o mesmo número e realmente se alinham. O jq usa um esquema próprio — não perca tempo tentando conciliá-lo.

Os três números diferem não porque alguém calculou errado, mas porque contam coisas diferentes. Python e Node informam onde o analisador parou; o jq informa onde começa o token que ele não conseguiu analisar. Entre os dois há alguns caracteres já consumidos — 9 nesta amostra.

2. Por que linha 1 coluna N é ruído neste tipo de arquivo

Números de linha são uma quantidade derivada que pressupõe "já faz um tempo desde a última quebra de linha". Um documento minificado tem um único \n, geralmente no fim, então:

  • lineno é sempre 1;
  • colno degenera em "quantos caracteres depois do início dessa única linha";
  • e essa linha tem quatro milhões de caracteres de largura.

Salte para a coluna 23611 em um editor e você não vê nada útil. Pior: ferramentas como less e vim se arrastam em linhas extremamente longas; mover o cursor com as setas consome vinte minutos.

Inverta a lógica: o deslocamento é a quantidade nativa, e linha/coluna são calculados a partir dele. Use a quantidade nativa.

3. Abordagem um: em Python, use e.pos e ignore lineno

JSONDecodeError carrega três números, e só pos vale a pena ler. lineno e colno são valores derivados que se reduzem a nada em um documento de uma única linha.

# 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')

Execute contra o arquivo quebrado:

$ 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",
                                                            ^

O circunflexo marca o ponto onde falta uma vírgula. Vinte minutos viram um comando.

Qual deve ser a largura da janela: ±60 caracteres quase sempre cobrem um par chave/valor completo. Estreita demais, você perde o contexto; larga demais, a saída enche a tela. Documentos muito aninhados podem ir a ±120.

4. Abordagem dois: sem Python no contêiner — use jq mais cut

Contêineres de produção muitas vezes vêm sem Python, mas quase sempre têm jq, ou podem obtê-lo com um apt-get install jq. O que o jq oferece é uma falha ruidosa:

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

O código de saída 5 significa entrada inválida, então sob set -e ele aborta o script do mesmo jeito que um grep retornando 1. Isso é um recurso.

O jq não tem flag para imprimir os bytes infratores, então entregue a posição ao 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",

Quando a vírgula volta, o jq pode fazer aquilo em que é realmente bom:

$ 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. Abordagem três: sem runtime, ou dados que não podem tocar o disco — cole no navegador

As duas primeiras abordagens exigem algo instalado e permissão para gravar arquivos. Se você está em um laptop pelado, ou a carga útil está cheia de e-mails de clientes e endereços IP e "eu colei em um site qualquer" é uma frase que você não quer no relatório de incidente, a terceira opção serve.

Cole a string inteira em um analisador JSON online e ele coloca o circunflexo no caractere infrator e rotula a linha e a coluna; a vantagem de uma ferramenta local é que esses 4 MB nunca saem da máquina e nunca caem nos logs de outra pessoa.

Duas ressalvas:

  1. Escolha uma ferramenta que diga explicitamente que processa tudo no navegador, nunca uma que envie seu conteúdo por POST a um backend;
  2. Colar tolera arquivos muito grandes, mas acima de algumas dezenas de megabytes a própria aba consome memória — volte à abordagem um nesse ponto.

6. Deslocamento de caractere não é deslocamento de byte: a segunda armadilha com dados CJK

Esta morde as duas abordagens em cargas úteis mistas de chinês/inglês.

JSONDecodeError.pos é um deslocamento de caractere (uma contagem de pontos de código Unicode), porque resulta da análise de uma str. O cut -c fatia por caractere e se alinha com ele. Mas head -c, tail -c e dd fatiam por byte, e sob UTF-8 um único deslize desalinha tudo.

Uma amostra chinesa torna isso concreto (5 pedidos, 148 caracteres, 222 bytes):

>>> 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","'

Mesmo deslocamento: a visão por caractere cai em um nome de campo, a visão por byte cai no fim de um id de pedido. Um único caractere CJK ocupa 3 bytes sob UTF-8, e a deriva se acumula com a posição.

Regra: depois de ler e.pos, fatie com cut -c ou com um slice Python, nunca com head -c / dd bs=1 skip=N. Se ferramentas de byte são tudo o que você tem, converta primeiro: len(raw[:error.pos].encode('utf-8')).

7. Sintaxe corrigida, dados ainda errados: três armadilhas silenciosas

É exatamente por isso que a depuração não pode parar em "foi analisado". Cada uma das armadilhas a seguir ou nunca levanta nada, ou levanta muito depois de o valor já ter sido usado.

Armadilha 1: chaves duplicadas — a última vence, ninguém reclama

A seção 4 da RFC 8259 é clara: nomes dentro de um objeto DEVERIAM ser únicos, e quando não são, o comportamento é imprevisível — a maioria das implementações mantém a última ocorrência, outras levantam erro. As três ferramentas medidas aqui mantêm a última:

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}

Nem um único aviso. Se o campo sobrescrito era amount_cents, você descobre quando a conciliação fica três centavos fora.

Armadilha 2: NaN e Infinity — Python os aceita, Node não

A seção 6 da RFC 8259 declara claramente que literais numéricos não contêm NaN nem Infinity. O json.loads do Python aceita ambos por padrão, porque o próprio float dele tem esses valores:

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

Entregue essa saída ao Node e você obtém:

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

Em outras palavras: dados ruins passam pelo Python e explodem no consumidor a jusante.

Armadilha 3: inteiros acima de 2^53 perdem precisão em silêncio

Um documento, dois valores diferentes:

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

A razão em uma linha: Number.MAX_SAFE_INTEGER do JavaScript é 9007199254740991 (2^53-1). O MDN ressalta especificamente que o reviver roda depois que o valor já foi analisado como um número JavaScript, então a precisão se perde antes do reviver — não há como recuperá-la do lado da desserialização. A única correção é o produtor serializar IDs grandes como strings.

As três armadilhas comparadas:

Armadilha Algo é levantado? Quem percebe primeiro Como interceptar
Chaves duplicadas Não Conciliação a jusante Verificação explícita via object_pairs_hook
NaN / Infinity Node levanta, Python silencia Consumidores a jusante Rejeitar na borda com parse_constant
Inteiros acima de 2^53 Não Código que faz comparações de igualdade Entregá-los como strings

Pegue as duas primeiras em uma passada

# 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')

O custo, dito com franqueza: na mesma amostra de 2.011.084 caracteres, esta versão estrita chega a 35,2-36,2 ms contra 9,4-9,9 ms da versão tolerante, cerca de 3,7 vezes mais lenta (3,6 a 3,8 em cinco rodadas de cinco execuções cada, mediana por rodada) — o object_pairs_hook executa um Counter para cada objeto. Mantenha-a fora do caminho feliz da CI e mude para ela só quando uma entrega parecer suspeita.

8. Checklist

Depois de corrigir o arquivo, é tentador formatá-lo para legibilidade, mas a mesma amostra mede 2.011.084 caracteres minificado (separators=(',',':')) e 2.869.100 caracteres com indent=2 — 43% a mais. Esse peso viaja para o histórico Git, os caches de CI e a transferência de rede, então mantenha a versão formatada localmente e esprema-a de volta para uma linha antes de entregar.

  • [ ] Procure primeiro por (char N) / position N; ignore line 1 column N;
  • [ ] Python usa e.pos, jq combina com cut -c — mesmos deslocamentos de caractere, então nunca concilie o column do jq com eles;
  • [ ] Para documentos CJK, fatie por caractere (cut -c, slices Python), nunca head -c / dd;
  • [ ] Scripts de contêiner devem tratar o código de saída 5 do jq como "entrada inválida";
  • [ ] Verifique novamente dados suspeitos com object_pairs_hook + parse_constant e aceite o custo de cerca de 3,7 vezes;
  • [ ] Entregue qualquer ID acima de 2^53 como string; não espere que um reviver o resgate;
  • [ ] A saída formatada (medida em +43%) fica local: use /minify para comprimir de volta a uma linha antes de entregar ou commitar; acima de alguns MB mude para streaming, coberto em Análise, renderização e streaming de JSON grande.

FAQ

Por que as três ferramentas informam uma posição diferente?

Porque contam coisas diferentes. O char N do Python e o position N do Node significam ambos "onde o analisador parou" — nesta amostra os dois valem 23610, então coincidem. O jq informa onde começa o token infrator, 23619 aqui, nove posições adiante. Ninguém calculou errado e nenhuma correção é necessária; compare números apenas dentro da mesma ferramenta.

Devo usar cut -c ou head -c?

Use cut -c. JSONDecodeError.pos é um deslocamento contado em caracteres Unicode, e cut -c fatia por caractere, então os dois concordam. head -c, tail -c e dd fatiam por byte, o que deriva em documentos UTF-8 que contêm texto CJK e pode até cortar um caractere ao meio. Na amostra chinesa, o mesmo deslocamento rende 城市":"杭州"}, na visão por caractere e DD00000"," na visão por byte.

O tratamento de chaves duplicadas é realmente sem proteção em todos os casos?

É. A seção 4 da RFC 8259 diz apenas que nomes DEVERIAM ser únicos e que o comportamento é imprevisível quando não são. Python, Node e jq medidos aqui aplicam todos a regra "a última vence", sem emitir nenhum aviso. Para pegá-lo é preciso um object_pairs_hook próprio, ao custo de cerca de 3,7 vezes o tempo de análise.

Por que Python lê NaN sem problema enquanto Node levanta erro?

A seção 6 da RFC 8259 proíbe NaN e Infinity como literais numéricos. O json.loads do Python os aceita por padrão porque o float do Python tem esses valores, então dados ruins passam pelo Python e são reescritos como estão. O JSON.parse do Node segue a especificação estritamente e levanta um SyntaxError nessa saída. Adicionar parse_constant no ponto de entrada os rejeita imediatamente.

Números de pedido com 20 dígitos sempre perdem precisão via JSON?

Só quando o consumidor os analisa como double IEEE 754. O int do Python tem precisão arbitrária e mantém 12345678901234567890; Number.MAX_SAFE_INTEGER do JavaScript é 9007199254740991, então o mesmo campo vira 12345678901234567000. O MDN observa explicitamente que o reviver roda depois da análise, então nada pode ser recuperado a jusante — a única correção confiável é o produtor emitir strings.