JSON Toolkit

Debug di JSON minificato: da line 1 column 23611 al byte esatto rotto

In sintesi: Un file JSON di 4 MB su una sola riga segnala line 1 column 23611, che non dice nulla. Usando campioni misurati localmente, questa guida spiega perché Python, Node e jq riportano tre numeri diversi, come un offset di carattere porta direttamente al byte rotto e quali tre trappole corrompono silenziosamente i dati dopo che la sintassi è stata corretta.

Avanzato~7 min di letturaPubblicato il 2026-11-01Aggiornato il 2026-11-01
Localizzare gli errori nel JSON minificato

In sintesi: i numeri di riga e colonna hanno senso solo nei documenti su più righe. Una volta che il JSON è minificato in una sola riga, ogni errore vive alla riga 1 — il numero che ti serve non è la riga, è l'indice del carattere.

Un documento JSON minificato ha esattamente una riga, quindi la coppia riga/colonna in un messaggio di errore riduce una posizione di quattro milioni di byte a una frase inutile. La soluzione è saltare del tutto riga/colonna e leggere invece l'offset del carattere — l'umile (char 23610) in fondo al messaggio è il numero che ti salva davvero.

Ogni numero qui sotto è stato prodotto localmente. Il campione è un documento su una sola riga con 13.000 eventi di fatturazione, 2.011.084 caratteri in totale, con una virgola eliminata di proposito dopo l'event_id dell'evento #154. L'ambiente è Python 3.11.1, Node 22.13.1 e jq 1.7.

1. Prima i fatti: tre parser, tre numeri

Lo stesso file rotto, passato a tre strumenti:

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

Confronto diretto:

Strumento Valore riportato Unità Contato da Perché riga/colonna fallisce
Python JSONDecodeError.pos 23610 Offset di carattere Inizio del buffer L'unico di cui fidarsi
Python lineno / colno 1 / 23611 Riga e colonna Inizio della riga Una sola riga, quindi non dice nulla
Node position 23610 Offset di carattere Inizio del buffer Affidabile, anche se il messaggio nasconde il campo
jq column 23619 Offset di carattere Inizio del token incriminato Diverso da Python di 9

La conclusione è netta: il char N di Python e il position N di Node sono lo stesso numero e coincidono. jq usa uno schema proprio — non perdere tempo a riconciliarlo.

I tre numeri differiscono non perché qualcuno abbia calcolato male, ma perché contano cose diverse. Python e Node riportano dove il parser si è fermato; jq riporta dove inizia il token che non è riuscito ad analizzare. Tra i due ci sono alcuni caratteri già consumati — 9 in questo campione.

2. Perché line 1 column N è rumore su questo tipo di file

I numeri di riga sono una quantità derivata che presuppone "è passato un po' dall'ultimo a capo". Un documento minificato ha un solo \n, di solito alla fine, quindi:

  • lineno vale sempre 1;
  • colno degenera in "quanti caratteri dopo l'inizio di quell'unica riga";
  • e quella riga è larga quattro milioni di caratteri.

Salta alla colonna 23611 in un editor e non vedi nulla di utile. Peggio: strumenti come less e vim strisciano su righe estremamente lunghe; spostare il cursore con i tasti freccia brucia venti minuti.

Gira il ragionamento: l'offset è la quantità nativa, e riga/colonna sono calcolate da essa. Usa la quantità nativa.

3. Primo approccio: in Python usa e.pos e ignora lineno

JSONDecodeError porta con sé tre numeri, e solo pos vale la pena di leggere. lineno e colno sono valori derivati che collassano nel nulla su un documento a una sola riga.

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

Eseguito sul file rotto:

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

Il caret segna il punto in cui manca una virgola. Venti minuti diventano un comando.

Quanto deve essere larga la finestra: ±60 caratteri coprono quasi sempre una coppia chiave/valore completa. Troppo stretta e perdi contesto; troppo larga e l'output riempie lo schermo. I documenti molto annidati possono arrivare a ±120.

4. Secondo approccio: niente Python nel container — jq più cut

I container di produzione spesso arrivano senza Python, ma quasi sempre hanno jq, oppure lo ottengono con un apt-get install jq. Quello che jq ti dà è un fallimento rumoroso:

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

Il codice di uscita 5 significa input non valido, quindi sotto set -e interrompe lo script come fa grep restituendo 1. È una caratteristica.

jq non ha un flag per stampare i byte incriminati, quindi passa la posizione a 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",

Una volta ripristinata la virgola, jq può fare ciò in cui è davvero bravo:

$ 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. Terzo approccio: nessun runtime, o dati che non devono toccare il disco — incolla tutto in un browser

I primi due approcci richiedono qualcosa di installato e il permesso di scrivere file. Se sei su un portatile spoglio, o il payload è pieno di email e indirizzi IP dei clienti e "l'ho incollato in un sito web" è una frase che non vuoi nel rapporto dell'incidente, la terza opzione fa al caso tuo.

Incolla l'intera stringa in un parser JSON online: mette il caret sul carattere incriminato e indica riga e colonna; il vantaggio di uno strumento locale è che quei 4 MB non lasciano mai la macchina e non finiscono nei log di qualcun altro.

Due avvertenze:

  1. Scegli uno strumento che dichiari esplicitamente di elaborare tutto nel browser, mai uno che fa POST del tuo contenuto a un backend;
  2. L'incolla tollera file molto grandi, ma oltre qualche decina di megabyte la scheda stessa consuma memoria — a quel punto torna al primo approccio.

6. Offset di carattere non è offset di byte: la seconda trappola con i dati CJK

Questa morde entrambi gli approcci sui payload misti cinese/inglese.

JSONDecodeError.pos è un offset di carattere (un conteggio di code point Unicode), perché deriva dall'analisi di una str. cut -c taglia per carattere e si allinea. Ma head -c, tail -c e dd tagliano per byte, e in UTF-8 un solo scivolone manda tutto fuori strada.

Un campione cinese lo rende concreto (5 ordini, 148 caratteri, 222 byte):

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

Stesso offset: la vista a caratteri atterra su un nome di campo, la vista a byte atterra sulla coda di un id ordine. Un singolo carattere CJK occupa 3 byte in UTF-8, e la deriva si accumula con la posizione.

Regola: dopo aver letto e.pos, taglia con cut -c o con uno slice Python, mai con head -c / dd bs=1 skip=N. Se hai solo strumenti a byte, converti prima: len(raw[:error.pos].encode('utf-8')).

7. Sintassi corretta, dati ancora sbagliati: tre trappole silenziose

È esattamente per questo che il debug non può fermarsi a "ha fatto il parsing". Ciascuna delle seguenti o non solleva mai, o solleva molto dopo che il valore è stato usato.

Trappola 1: chiavi duplicate — vince l'ultima, nessuno protesta

La sezione 4 di RFC 8259 è chiara: i nomi dentro un oggetto SHOULD essere unici, e quando non lo sono il comportamento è imprevedibile — la maggior parte delle implementazioni tiene l'ultima occorrenza, altre sollevano. Tutti e tre gli strumenti misurati qui tengono l'ultima:

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}

Nemmeno un avviso. Se il campo sovrascritto era amount_cents, lo scopri quando la riconciliazione è fuori di tre centesimi.

Trappola 2: NaN e Infinity — Python li accetta, Node no

La sezione 6 di RFC 8259 afferma chiaramente che i letterali numerici non contengono NaN né Infinity. Il json.loads di Python accetta entrambi per impostazione predefinita, perché il suo stesso float ha quei valori:

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

Passa quell'output a Node e ottieni:

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

In altre parole: i dati cattivi passano Python e detonano nel consumatore a valle.

Trappola 3: gli interi oltre 2^53 perdono precisione in silenzio

Un documento, due valori diversi:

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

La ragione in una riga: Number.MAX_SAFE_INTEGER di JavaScript è 9007199254740991 (2^53-1). MDN sottolinea in particolare che il reviver gira dopo che il valore è già stato analizzato in un number JavaScript, quindi la precisione si perde prima del reviver — non può essere recuperata sul lato della deserializzazione. L'unica correzione è che il produttore serializzi gli ID grandi come stringhe.

Le tre trappole confrontate:

Trappola Qualcosa solleva? Chi se ne accorge per primo Come intercettarla
Chiavi duplicate No Riconciliazione a valle Controllo esplicito con object_pairs_hook
NaN / Infinity Node solleva, Python tace Consumatori a valle Rifiuto al confine con parse_constant
Interi oltre 2^53 No Codice che fa confronti di uguaglianza Trasmetterli come stringhe

Catturare le prime due in un solo passaggio

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

Il costo, detto con onestà: sullo stesso campione da 2.011.084 caratteri, questa versione rigorosa si attesta a 35.2-36.2 ms contro 9.4-9.9 ms della versione permissiva, circa 3.7 volte più lenta (da 3.6x a 3.8x su cinque giri di cinque esecuzioni ciascuno, mediana per giro) — object_pairs_hook esegue un Counter per ogni oggetto. Tienila fuori dal percorso felice della CI e passaci solo quando una consegna sembra sospetta.

8. Checklist

Dopo aver corretto il file viene la tentazione di formattarlo per leggibilità, ma lo stesso campione misura 2.011.084 caratteri minificato (separators=(',',':')) e 2.869.100 caratteri con indent=2 — il 43% in più. Quel peso viaggia nella cronologia Git, nelle cache della CI e nel trasferimento di rete, quindi tieni la versione formattata in locale e ricomprimila su una riga prima di pubblicarla.

  • [ ] Cerca prima (char N) / position N; ignora line 1 column N;
  • [ ] Python usa e.pos, jq si abbina a cut -c — stessi offset di carattere, quindi non riconciliare mai la column di jq con essi;
  • [ ] Per i documenti CJK taglia per carattere (cut -c, slice Python), mai head -c / dd;
  • [ ] Gli script nei container devono trattare il codice di uscita 5 di jq come "input non valido";
  • [ ] Ricontrolla i dati sospetti con object_pairs_hook + parse_constant e accetta il costo di circa 3.7 volte;
  • [ ] Spedisci qualsiasi ID sopra 2^53 come stringa; non aspettarti che un reviver lo salvi;
  • [ ] L'output formattato (misurato +43%) resta in locale: usa /minify per ricomprimerlo su una riga prima di spedire o committare; sopra qualche MB passa invece allo streaming, trattato in Parsing, rendering e streaming di JSON di grandi dimensioni.

Domande frequenti

Perché tutti e tre gli strumenti riportano una posizione diversa?

Perché contano cose diverse. Il char N di Python e il position N di Node significano entrambi "dove il parser si è fermato" — su questo campione sono entrambi 23610, quindi coincidono. jq riporta dove inizia il token incriminato, 23619 qui, nove più avanti. Nessuno ha calcolato male e non serve alcuna correzione; confronta i numeri solo all'interno dello stesso strumento.

Devo usare cut -c o head -c?

Usa cut -c. JSONDecodeError.pos è un offset contato in caratteri Unicode, e cut -c taglia per carattere, quindi i due concordano. head -c, tail -c e dd tagliano per byte, il che deriva sui documenti UTF-8 con testo CJK e può persino tagliare un carattere a metà. Sul campione cinese, lo stesso offset produce 城市":"杭州"}, dalla vista a caratteri e DD00000"," dalla vista a byte.

La gestione delle chiavi duplicate è davvero senza protezioni ovunque?

Sì. La sezione 4 di RFC 8259 dice solo che i nomi SHOULD essere unici e afferma che il comportamento è imprevedibile quando non lo sono. Python, Node e jq, tutti misurati qui, applicano l'ultimo-vince senza emettere alcun avviso. Catturarlo richiede un tuo object_pairs_hook, al costo di circa 3.7 volte il tempo di parsing.

Perché Python legge bene NaN mentre Node lancia?

La sezione 6 di RFC 8259 vieta NaN e Infinity come letterali numerici. Il json.loads di Python li accetta per impostazione predefinita perché il float di Python ha quei valori, quindi i dati cattivi passano Python e vengono riscritti così come sono. Il JSON.parse di Node segue la specifica alla lettera e lancia un SyntaxError su quell'output. Aggiungere parse_constant al punto di ingresso lo rifiuta subito.

I numeri d'ordine a 20 cifre perdono sempre precisione attraverso JSON?

Solo quando il consumatore li analizza in un double IEEE 754. L'int di Python è a precisione arbitraria e conserva 12345678901234567890; Number.MAX_SAFE_INTEGER di JavaScript è 9007199254740991, quindi lo stesso campo diventa 12345678901234567000. MDN nota esplicitamente che il reviver gira dopo il parsing, quindi nulla può essere recuperato a valle — l'unica correzione affidabile è che il produttore emetta stringhe.