Déboguer du JSON minifié : de la ligne 1 colonne 23611 à l'octet défectueux exact
L’essentiel : Un fichier JSON d'une seule ligne de 4 Mo signale la ligne 1 colonne 23611, ce qui ne vous dit rien. À partir d'échantillons mesurés localement, ce guide explique pourquoi Python, Node et jq rapportent trois nombres différents, comment un décalage de caractère vous mène droit à l'octet défectueux, et quels trois pièges corrompent silencieusement les données une fois la syntaxe corrigée.

L'essentiel : les numéros de ligne et de colonne n'ont de sens que dans un document réparti sur plusieurs lignes. Une fois le JSON minifié en une seule ligne, toutes les erreurs vivent sur la ligne 1 — le nombre qu'il vous faut n'est pas la ligne, c'est l'index de caractère.
Un document JSON minifié n'a qu'une seule ligne, donc le couple ligne/colonne d'un message d'erreur écrase une position de quatre millions d'octets en une formule inutile. La parade consiste à ignorer complètement ligne/colonne et à lire le décalage de caractère — le modeste
(char 23610)à la fin du message est le nombre qui vous sauve vraiment.
Tous les chiffres ci-dessous ont été produits localement. L'échantillon est un document d'une seule ligne contenant 13 000 événements de facturation, 2 011 084 caractères au total, avec une virgule supprimée volontairement après le event_id de l'événement n°154. L'environnement est Python 3.11.1, Node 22.13.1 et jq 1.7.
1. D'abord les faits : trois analyseurs, trois nombres
Le même fichier défectueux, soumis à trois outils :
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
Côte à côte :
| Outil | Valeur rapportée | Unité | Compté depuis | Pourquoi ligne/colonne échoue |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Décalage de caractère | Début du tampon | Le seul auquel vous pouvez vous fier |
Python lineno / colno |
1 / 23611 | Ligne et colonne | Début de ligne | Une seule ligne, donc aucune information |
Node position |
23610 | Décalage de caractère | Début du tampon | Fiable, même si le message cache le champ |
jq column |
23619 | Décalage de caractère | Début du jeton fautif | Diffère de Python de 9 |
Le constat est direct : le char N de Python et le position N de Node sont le même nombre et s'alignent vraiment. jq utilise son propre schéma — ne perdez pas de temps à le réconcilier.
Les trois nombres diffèrent non pas parce que quelqu'un s'est trompé dans le calcul, mais parce qu'ils comptent des choses différentes. Python et Node indiquent où l'analyseur s'est arrêté ; jq indique où commence le jeton qu'il n'a pas pu analyser. Une poignée de caractères déjà consommés séparent les deux — 9 dans cet échantillon.
2. Pourquoi ligne 1 colonne N est du bruit sur ce type de fichier
Les numéros de ligne sont une quantité dérivée qui suppose « il s'est écoulé un moment depuis le dernier saut de ligne ». Un document minifié a un seul \n, généralement à la fin, donc :
linenovaut toujours 1 ;colnodégénère en « combien de caractères après le début de cette unique ligne » ;- et cette ligne fait quatre millions de caractères de large.
Sautez à la colonne 23611 dans un éditeur et vous ne verrez rien d'utile. Pire, des outils comme less et vim rampent sur des lignes extrêmement longues ; déplacer le curseur aux flèches prend vingt minutes.
Retournez la logique : le décalage est la quantité native, et ligne/colonne sont calculés à partir de lui. Utilisez la quantité native.
3. Approche un : en Python, utilisez e.pos et ignorez lineno
JSONDecodeError porte trois nombres, et seul pos vaut la peine d'être lu. lineno et colno sont des valeurs dérivées qui s'effondrent sur un document d'une seule ligne.
# 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')
Lancez-le contre le fichier défectueux :
$ 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",
^
Le caret marque l'endroit où une virgule manque. Vingt minutes deviennent une commande.
Quelle largeur pour la fenêtre : ±60 caractères couvrent presque toujours une paire clé/valeur complète. Trop étroite, vous perdez le contexte ; trop large, la sortie remplit l'écran. Les documents très imbriqués peuvent monter à ±120.
4. Approche deux : pas de Python dans le conteneur — utilisez jq plus cut
Les conteneurs de production sont souvent livrés sans Python, mais ils ont presque toujours jq, ou peuvent l'obtenir avec un apt-get install jq. Ce que jq vous donne, c'est un échec bruyant :
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Le code de sortie 5 signifie une entrée invalide, donc sous set -e, il interrompt le script de la même façon qu'un grep retournant 1. C'est une fonctionnalité.
jq n'a pas d'option pour imprimer les octets fautifs, alors passez la position à 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",
Une fois la virgule revenue, jq peut faire ce pour quoi il est vraiment doué :
$ 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. Approche trois : aucun runtime, ou des données qui ne doivent pas toucher le disque — collez-les dans un navigateur
Les deux premières approches exigent quelque chose d'installé et la permission d'écrire des fichiers. Si vous êtes sur un portable nu, ou si la charge utile est pleine d'e-mails de clients et d'adresses IP et que « je l'ai collée sur un site web » est une phrase que vous ne voulez pas voir dans le rapport d'incident, la troisième option convient.
Collez la chaîne entière dans un analyseur JSON en ligne et il place le caret sur le caractère fautif et étiquette la ligne et la colonne ; l'avantage d'un outil local est que ces 4 Mo ne quittent jamais la machine et n'atterrissent jamais dans les logs de quelqu'un d'autre.
Deux réserves :
- Choisissez un outil qui dit explicitement qu'il traite tout dans le navigateur, jamais un outil qui POSTe votre contenu vers un backend ;
- Le collage tolère de très gros fichiers, mais au-delà de quelques dizaines de mégaoctets l'onglet lui-même consomme la mémoire — revenez à l'approche un à ce moment-là.
6. Le décalage de caractère n'est pas un décalage d'octet : le second piège avec les données CJK
Celui-ci mord les deux approches sur les charges utiles mixtes chinois/anglais.
JSONDecodeError.pos est un décalage de caractère (un comptage de points de code Unicode), parce qu'il résulte de l'analyse d'une str. cut -c découpe par caractère et s'aligne avec lui. Mais head -c, tail -c et dd découpent par octet, et sous UTF-8 un seul écart décale tout.
Un échantillon chinois rend cela concret (5 commandes, 148 caractères, 222 octets) :
>>> 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","'
Même décalage : la vue par caractère tombe sur un nom de champ, la vue par octet tombe sur la fin d'un identifiant de commande. Un seul caractère CJK occupe 3 octets sous UTF-8, et la dérive s'accumule avec la position.
Règle : après avoir lu e.pos, découpez avec cut -c ou une tranche Python, jamais avec head -c / dd bs=1 skip=N. Si les outils par octet sont tout ce que vous avez, convertissez d'abord : len(raw[:error.pos].encode('utf-8')).
7. Syntaxe corrigée, données toujours fausses : trois pièges silencieux
C'est exactement pourquoi le débogage ne peut pas s'arrêter à « ça s'est analysé ». Chacun des pièges suivants soit ne lève jamais rien, soit lève longtemps après que la valeur a servi.
Piège 1 : clés dupliquées — la dernière gagne, personne ne se plaint
La section 4 de la RFC 8259 est claire : les noms au sein d'un objet DEVRAIENT être uniques, et quand ils ne le sont pas, le comportement est imprévisible — la plupart des implémentations gardent la dernière occurrence, d'autres lèvent une erreur. Les trois outils mesurés ici gardent la dernière :
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}
Pas un seul avertissement. Si le champ écrasé était amount_cents, vous le découvrez quand le rapprochement est décalé de trois centimes.
Piège 2 : NaN et Infinity — Python les accepte, Node non
La section 6 de la RFC 8259 énonce clairement que les littéraux numériques ne contiennent ni NaN ni Infinity. json.loads de Python accepte les deux par défaut, parce que son propre float possède ces valeurs :
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Passez cette sortie à Node et vous obtenez :
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Autrement dit : les mauvaises données passent Python et explosent chez le consommateur en aval.
Piège 3 : les entiers au-delà de 2^53 perdent leur précision en silence
Un document, deux valeurs différentes :
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
La raison en une ligne : Number.MAX_SAFE_INTEGER de JavaScript vaut 9007199254740991 (2^53-1). MDN souligne spécifiquement que le reviver s'exécute après que la valeur a déjà été analysée en nombre JavaScript, donc la précision est perdue avant le reviver — elle ne peut pas être récupérée du côté désérialisation. La seule correction est que le producteur sérialise les grands identifiants sous forme de chaînes.
Les trois pièges comparés :
| Piège | Quelque chose se lève-t-il ? | Qui s'en aperçoit en premier | Comment l'intercepter |
|---|---|---|---|
| Clés dupliquées | Non | Le rapprochement en aval | Vérification explicite via object_pairs_hook |
| NaN / Infinity | Node lève, Python se tait | Les consommateurs en aval | Rejeter à l'entrée avec parse_constant |
| Entiers au-delà de 2^53 | Non | Le code qui fait des comparaisons d'égalité | Les livrer sous forme de chaînes |
Attraper les deux premiers en une passe
# 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')
Le coût, dit franchement : sur le même échantillon de 2 011 084 caractères, cette version stricte atteint 35,2-36,2 ms contre 9,4-9,9 ms pour la version tolérante, soit environ 3,7 fois plus lent (de 3,6 à 3,8 fois sur cinq séries de cinq exécutions chacune, médiane par série) — object_pairs_hook exécute un Counter pour chaque objet. Gardez-la hors du chemin heureux de la CI et basculez dessus seulement quand une livraison paraît suspecte.
8. Liste de contrôle
Après avoir corrigé le fichier, il est tentant de le formater pour la lisibilité, mais le même échantillon mesure 2 011 084 caractères minifié (separators=(',',':')) et 2 869 100 caractères avec indent=2 — 43 % de plus. Ce poids voyage dans l'historique Git, les caches de CI et le transfert réseau, alors gardez la version formatée en local et resserrez-la sur une ligne avant de livrer.
- [ ] Cherchez d'abord
(char N)/position N; ignorezline 1 column N; - [ ] Python utilise
e.pos, jq s'associe àcut -c— mêmes décalages de caractère, alors ne réconciliez jamais lecolumnde jq avec eux ; - [ ] Pour les documents CJK, découpez par caractère (
cut -c, tranches Python), jamaishead -c/dd; - [ ] Les scripts de conteneur doivent traiter le code de sortie 5 de jq comme « entrée invalide » ;
- [ ] Revérifiez les données suspectes avec
object_pairs_hook+parse_constantet acceptez le coût d'environ 3,7 fois ; - [ ] Livrez tout identifiant au-dessus de 2^53 sous forme de chaîne ; n'attendez pas qu'un reviver le sauve ;
- [ ] La sortie formatée (mesurée à +43 %) reste locale : utilisez /minify pour la recompresser sur une ligne avant de livrer ou de committer ; au-delà de quelques Mo, passez plutôt au streaming, traité dans Analyser, afficher et diffuser de gros JSON.
FAQ
Pourquoi les trois outils rapportent-ils une position différente ?
Parce qu'ils comptent des choses différentes. Le char N de Python et le position N de Node signifient tous deux « où l'analyseur s'est arrêté » — sur cet échantillon les deux valent 23610, donc ils correspondent. jq indique où commence le jeton fautif, 23619 ici, neuf caractères plus loin. Personne n'a mal calculé et aucune correction n'est nécessaire ; ne comparez des nombres qu'au sein d'un même outil.
Dois-je utiliser cut -c ou head -c ?
Utilisez cut -c. JSONDecodeError.pos est un décalage compté en caractères Unicode, et cut -c découpe par caractère, donc les deux s'accordent. head -c, tail -c et dd découpent par octet, ce qui dérive sur les documents UTF-8 contenant du texte CJK et peut même couper un caractère en deux. Sur l'échantillon chinois, le même décalage donne 城市":"杭州"}, vu par caractère et DD00000"," vu par octet.
La gestion des clés dupliquées est-elle vraiment sans garde partout ?
Oui. La section 4 de la RFC 8259 dit seulement que les noms DEVRAIENT être uniques et que le comportement est imprévisible quand ils ne le sont pas. Python, Node et jq mesurés ici appliquent tous la règle « la dernière gagne » sans émettre aucun avertissement. L'attraper exige votre propre object_pairs_hook, au prix d'environ 3,7 fois le temps d'analyse.
Pourquoi Python lit-il NaN sans problème alors que Node lève une erreur ?
La section 6 de la RFC 8259 interdit NaN et Infinity comme littéraux numériques. json.loads de Python les accepte par défaut parce que le float de Python possède ces valeurs, donc les mauvaises données passent Python et sont réécrites telles quelles. JSON.parse de Node suit la spécification strictement et lève une SyntaxError sur cette sortie. Ajouter parse_constant au point d'entrée les rejette immédiatement.
Les numéros de commande à 20 chiffres perdent-ils toujours leur précision via JSON ?
Seulement quand le consommateur les analyse en double IEEE 754. L'int de Python est à précision arbitraire et conserve 12345678901234567890 ; Number.MAX_SAFE_INTEGER de JavaScript vaut 9007199254740991, donc le même champ devient 12345678901234567000. MDN note explicitement que le reviver s'exécute après l'analyse, donc rien ne peut être récupéré en aval — la seule correction fiable est que le producteur émette des chaînes.