Minifiziertes JSON debuggen: von Zeile 1, Spalte 23611 zum exakten fehlerhaften Byte
Fazit: Eine einzeilige JSON-Datei mit 4 MB meldet Zeile 1, Spalte 23611 — das sagt Ihnen nichts. Anhand lokal gemessener Beispiele erklärt dieser Leitfaden, warum Python, Node und jq drei verschiedene Zahlen melden, wie ein Zeichenoffset Sie direkt zum fehlerhaften Byte führt und welche drei Fallen nach der Syntaxkorrektur stillschweigend Daten verfälschen.

Kurz gesagt: Zeilen- und Spaltennummern ergeben nur in mehrzeiligen Dokumenten einen Sinn. Sobald JSON auf eine einzige Zeile minifiziert ist, sitzt jeder Fehler in Zeile 1 — die Zahl, die Sie brauchen, ist nicht die Zeile, sondern der Zeichenindex.
Ein minifiziertes JSON-Dokument hat genau eine Zeile, daher presst das Zeile/Spalte-Paar einer Fehlermeldung eine Position von vier Millionen Bytes zu einem nutzlosen Satz zusammen. Der Ausweg besteht darin, Zeile/Spalte vollständig zu überspringen und stattdessen den Zeichenoffset zu lesen — das unscheinbare
(char 23610)am Ende der Meldung ist die Zahl, die Sie wirklich rettet.
Jede Zahl unten wurde lokal erzeugt. Die Probe ist ein einzeiliges Dokument mit 13.000 Abrechnungsereignissen, insgesamt 2.011.084 Zeichen, bei dem nach der event_id von Ereignis Nr. 154 absichtlich ein Komma entfernt wurde. Die Umgebung ist Python 3.11.1, Node 22.13.1 und jq 1.7.
1. Zuerst die Fakten: drei Parser, drei Zahlen
Dieselbe defekte Datei, an drei Werkzeuge übergeben:
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
Im Vergleich:
| Werkzeug | Gemeldeter Wert | Einheit | Gezählt ab | Warum Zeile/Spalte versagt |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Zeichenoffset | Anfang des Puffers | Die einzige verlässliche Zahl |
Python lineno / colno |
1 / 23611 | Zeile und Spalte | Zeilenanfang | Nur eine Zeile, sagt also nichts |
Node position |
23610 | Zeichenoffset | Anfang des Puffers | Zuverlässig, auch wenn die Meldung das Feld verbirgt |
jq column |
23619 | Zeichenoffset | Anfang des fehlerhaften Tokens | Weicht um 9 von Python ab |
Das Fazit ist deutlich: Pythons char N und Nodes position N sind dieselbe Zahl und stimmen überein. jq nutzt ein eigenes Schema — verschwenden Sie keine Zeit mit dem Abgleich.
Die drei Zahlen unterscheiden sich nicht, weil jemand falsch gerechnet hätte, sondern weil sie Verschiedenes zählen. Python und Node melden, wo der Parser stehen blieb; jq meldet, wo das Token beginnt, das es nicht parsen konnte. Dazwischen liegen einige bereits konsumierte Zeichen — in dieser Probe 9.
2. Warum Zeile 1 Spalte N bei dieser Art Datei nur Rauschen ist
Zeilennummern sind eine abgeleitete Größe, die voraussetzt, dass „es schon eine Weile seit dem letzten Zeilenumbruch her ist“. Ein minifiziertes Dokument hat ein einziges \n, meist am Ende, also:
linenoist immer 1;colnoentartet zu „wie viele Zeichen hinter dem Anfang dieser einen Zeile“;- und diese Zeile ist vier Millionen Zeichen breit.
Springen Sie in einem Editor zu Spalte 23611, sehen Sie nichts Nützliches. Schlimmer noch: Werkzeuge wie less und vim kriechen auf extrem langen Zeilen; das Verschieben des Cursors mit den Pfeiltasten kostet zwanzig Minuten.
Drehen Sie es um: Der Offset ist die native Größe, und Zeile/Spalte werden daraus berechnet. Verwenden Sie die native Größe.
3. Ansatz eins: in Python e.pos verwenden und lineno ignorieren
JSONDecodeError trägt drei Zahlen, und nur pos ist es wert, gelesen zu werden. lineno und colno sind abgeleitete Werte, die auf einem einzeiligen Dokument zu nichts zerfallen.
# 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')
Führen Sie es gegen die defekte Datei aus:
$ 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",
^
Die Caret-Markierung zeigt die Stelle, an der ein Komma fehlt. Zwanzig Minuten werden zu einem Befehl.
Wie breit sollte das Fenster sein: ±60 Zeichen umfassen fast immer ein vollständiges Schlüssel/Wert-Paar. Zu schmal, und Sie verlieren den Kontext; zu breit, und die Ausgabe füllt den Bildschirm. Tief verschachtelte Dokumente können auf ±120 gehen.
4. Ansatz zwei: kein Python im Container — jq plus cut verwenden
Produktionscontainer werden oft ohne Python ausgeliefert, haben aber fast immer jq oder können es mit einem apt-get install jq bekommen. Was jq liefert, ist ein lautes Scheitern:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Exit-Code 5 bedeutet ungültige Eingabe, also bricht es unter set -e das Skript genauso ab wie ein grep, das 1 zurückgibt. Das ist ein Feature.
jq hat kein Flag zum Ausgeben der fehlerhaften Bytes, also übergeben Sie die Position an 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",
Sobald das Komma wieder da ist, kann jq tun, was es wirklich gut kann:
$ 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. Ansatz drei: keine Runtime oder Daten, die die Festplatte nicht berühren dürfen — in den Browser einfügen
Die ersten beiden Ansätze erfordern etwas Installiertes und die Erlaubnis, Dateien zu schreiben. Wenn Sie an einem nackten Laptop sitzen oder die Nutzlast voller Kunden-E-Mails und IP-Adressen ist und „ich habe es in irgendeine Website eingefügt“ ein Satz ist, den Sie nicht im Incident-Bericht haben wollen, passt die dritte Option.
Fügen Sie die ganze Zeichenkette in einen Online-JSON-Parser ein, und er setzt die Caret-Markierung auf das fehlerhafte Zeichen und beschriftet Zeile und Spalte; der Vorteil eines lokalen Werkzeugs ist, dass diese 4 MB die Maschine nie verlassen und nie in den Logs von jemand anderem landen.
Zwei Vorbehalte:
- Wählen Sie ein Werkzeug, das ausdrücklich sagt, dass es alles im Browser verarbeitet, niemals eines, das Ihren Inhalt per POST an ein Backend schickt;
- Das Einfügen verträgt sehr große Dateien, aber jenseits einiger Dutzend Megabyte frisst der Tab selbst Speicher — kehren Sie dann zu Ansatz eins zurück.
6. Zeichenoffset ist nicht Byteoffset: die zweite Falle bei CJK-Daten
Diese beißt beide Ansätze bei gemischten chinesisch/englischen Nutzlasten.
JSONDecodeError.pos ist ein Zeichenoffset (eine Zählung von Unicode-Codepunkten), weil es aus dem Parsen einer str entsteht. cut -c schneidet nach Zeichen und passt dazu. Aber head -c, tail -c und dd schneiden nach Byte, und unter UTF-8 bringt ein einziger Fehltritt alles durcheinander.
Ein chinesisches Beispiel macht es konkret (5 Bestellungen, 148 Zeichen, 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","'
Gleicher Offset: die Zeichenansicht landet auf einem Feldnamen, die Byteansicht landet auf dem Ende einer Bestell-ID. Ein einzelnes CJK-Zeichen belegt unter UTF-8 3 Bytes, und die Drift summiert sich mit der Position.
Regel: Nach dem Lesen von e.pos mit cut -c oder einem Python-Slice schneiden, niemals mit head -c / dd bs=1 skip=N. Wenn Byte-Werkzeuge alles sind, was Sie haben, zuerst umrechnen: len(raw[:error.pos].encode('utf-8')).
7. Syntax repariert, Daten weiterhin falsch: drei stille Fallen
Genau deshalb kann Debugging nicht bei „es wurde geparst“ aufhören. Jede der folgenden Fallen löst entweder nie etwas aus oder erst lange, nachdem der Wert verwendet wurde.
Falle 1: doppelte Schlüssel — der letzte gewinnt, niemand beschwert sich
RFC 8259 Abschnitt 4 ist klar: Namen innerhalb eines Objekts SOLLTEN eindeutig sein, und wenn sie es nicht sind, ist das Verhalten unvorhersehbar — die meisten Implementierungen behalten das letzte Vorkommen, andere werfen einen Fehler. Alle drei hier gemessenen Werkzeuge behalten das letzte:
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}
Keine einzige Warnung. War das überschriebene Feld amount_cents, erfahren Sie es, wenn die Abstimmung um drei Cent danebenliegt.
Falle 2: NaN und Infinity — Python akzeptiert sie, Node nicht
RFC 8259 Abschnitt 6 stellt klar, dass Zahlenliterale weder NaN noch Infinity enthalten. Pythons json.loads akzeptiert beide standardmäßig, weil das eigene float diese Werte hat:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Geben Sie diese Ausgabe an Node, und Sie erhalten:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Mit anderen Worten: schlechte Daten passieren Python und explodieren beim nachgelagerten Konsumenten.
Falle 3: Ganzzahlen jenseits von 2^53 verlieren stillschweigend an Präzision
Ein Dokument, zwei verschiedene Werte:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
Der Grund in einer Zeile: JavaScripts Number.MAX_SAFE_INTEGER ist 9007199254740991 (2^53-1). MDN weist ausdrücklich darauf hin, dass der reviver nach dem Parsen des Werts zu einer JavaScript-Zahl läuft, die Präzision also vor dem reviver verloren ist — sie lässt sich auf der deserialisierenden Seite nicht zurückholen. Die einzige Abhilfe ist, dass der Produzent große IDs als Strings serialisiert.
Die drei Fallen im Vergleich:
| Falle | Wird etwas ausgelöst? | Wer merkt es zuerst | Wie abfangen |
|---|---|---|---|
| Doppelte Schlüssel | Nein | Nachgelagerte Abstimmung | Ausdrückliche Prüfung über object_pairs_hook |
| NaN / Infinity | Node wirft, Python schweigt | Nachgelagerte Konsumenten | An der Grenze mit parse_constant ablehnen |
| Ganzzahlen jenseits von 2^53 | Nein | Code mit Gleichheitsprüfungen | Als Strings ausliefern |
Die ersten beiden in einem Durchgang abfangen
# 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')
Die Kosten, ehrlich gesagt: Auf derselben Probe mit 2.011.084 Zeichen liegt diese strenge Version bei 35,2-36,2 ms gegenüber 9,4-9,9 ms für die nachsichtige, also etwa 3,7-mal langsamer (3,6 bis 3,8 über fünf Runden zu je fünf Läufen, Median pro Runde) — object_pairs_hook führt für jedes Objekt einen Counter aus. Halten Sie sie aus dem Happy Path der CI heraus und wechseln Sie nur darauf, wenn eine Lieferung verdächtig aussieht.
8. Checkliste
Nach der Reparatur der Datei ist es verlockend, sie zur Lesbarkeit hübsch zu formatieren, aber dieselbe Probe misst 2.011.084 Zeichen minifiziert (separators=(',',':')) und 2.869.100 Zeichen bei indent=2 — 43 % mehr. Dieses Gewicht wandert in die Git-Historie, CI-Caches und die Netzwerkübertragung, also halten Sie die formatierte Version lokal und quetschen Sie sie vor dem Ausliefern wieder auf eine Zeile.
- [ ] Zuerst nach
(char N)/position Nsuchen;line 1 column Nignorieren; - [ ] Python nutzt
e.pos, jq paart mitcut -c— gleiche Zeichenoffsets, also niemals jqscolumndamit abgleichen; - [ ] Bei CJK-Dokumenten nach Zeichen schneiden (
cut -c, Python-Slices), niemalshead -c/dd; - [ ] Container-Skripte sollten jqs Exit-Code 5 als „Eingabe ungültig“ behandeln;
- [ ] Verdächtige Daten mit
object_pairs_hook+parse_constanterneut prüfen und die etwa 3,7-fachen Kosten akzeptieren; - [ ] Jede ID über 2^53 als String ausliefern; erwarten Sie nicht, dass ein reviver sie rettet;
- [ ] Hübsch formatierte Ausgabe (gemessen +43 %) bleibt lokal: mit /minify vor dem Ausliefern oder Committen wieder auf eine Zeile komprimieren; oberhalb einiger MB stattdessen auf Streaming umschalten, behandelt in Großes JSON parsen, rendern und streamen.
FAQ
Warum melden alle drei Werkzeuge eine andere Position?
Weil sie Verschiedenes zählen. Pythons char N und Nodes position N bedeuten beide „wo der Parser stehen blieb“ — in dieser Probe sind beide 23610, sie stimmen also überein. jq meldet, wo das fehlerhafte Token beginnt, hier 23619, neun Zeichen weiter. Niemand hat sich verrechnet, und keine Korrektur ist nötig; vergleichen Sie Zahlen nur innerhalb desselben Werkzeugs.
Sollte ich cut -c oder head -c verwenden?
Verwenden Sie cut -c. JSONDecodeError.pos ist ein in Unicode-Zeichen gezählter Offset, und cut -c schneidet nach Zeichen, sodass beide übereinstimmen. head -c, tail -c und dd schneiden nach Byte, was bei UTF-8-Dokumenten mit CJK-Text driftet und sogar ein Zeichen halbieren kann. In der chinesischen Probe ergibt derselbe Offset 城市":"杭州"}, aus der Zeichenansicht und DD00000"," aus der Byteansicht.
Ist die Behandlung doppelter Schlüssel wirklich überall ungeschützt?
Ja. RFC 8259 Abschnitt 4 sagt nur, dass Namen eindeutig sein SOLLTEN, und dass das Verhalten unvorhersehbar ist, wenn sie es nicht sind. Python, Node und jq wenden hier alle „der letzte gewinnt“ an, ohne eine Warnung auszugeben. Um es zu fangen, braucht man einen eigenen object_pairs_hook — zum Preis von etwa 3,7-facher Parse-Zeit.
Warum liest Python NaN problemlos, während Node wirft?
RFC 8259 Abschnitt 6 verbietet NaN und Infinity als Zahlenliterale. Pythons json.loads akzeptiert sie standardmäßig, weil Pythons eigener float diese Werte hat, sodass schlechte Daten Python passieren und direkt wieder herausgeschrieben werden. Nodes JSON.parse folgt der Spezifikation strikt und wirft bei dieser Ausgabe einen SyntaxError. Das Hinzufügen von parse_constant am Eintrittspunkt lehnt sie sofort ab.
Verlieren 20-stellige Bestellnummern über JSON immer an Präzision?
Nur wenn der Konsument sie in einen IEEE-754-Double parst. Pythons int hat beliebige Präzision und behält 12345678901234567890; JavaScripts Number.MAX_SAFE_INTEGER ist 9007199254740991, also wird dasselbe Feld zu 12345678901234567000. MDN merkt ausdrücklich an, dass der reviver nach dem Parsen läuft, sodass nachgelagert nichts zurückgeholt werden kann — die einzige verlässliche Abhilfe ist, dass der Produzent Strings ausgibt.