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.

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;colnodegenera 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:
- Escolha uma ferramenta que diga explicitamente que processa tudo no navegador, nunca uma que envie seu conteúdo por POST a um backend;
- 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; ignoreline 1 column N; - [ ] Python usa
e.pos, jq combina comcut -c— mesmos deslocamentos de caractere, então nunca concilie ocolumndo jq com eles; - [ ] Para documentos CJK, fatie por caractere (
cut -c, slices Python), nuncahead -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_constante 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.