JSON Toolkit

Quand deux documents JSON diffèrent : diff positionnel, diff par identifiant, ou normaliser d'abord

L’essentiel : Cinquante enregistrements deviennent cinquante-et-un et le diff rapporte cinquante-et-un changements. Couvre les faux positifs du diff texte, l'appariement des tableaux par id, la normalisation jq et les patchs applicables.

Débutant~6 min de lecturePublié le 2026-10-30Mis à jour le 2026-10-30
Illustration de la comparaison de deux documents JSON

L'essentiel : normalisez d'abord, puis comparez — utilisez jq -S ou une sérialisation stable pour effacer l'ordre des clés, l'indentation et l'orthographe des nombres, triez ou indexez les tableaux par une clé métier quand l'ordre n'a pas de sens, et seulement ensuite transformez le diff en patch.

Une différence JSON n'est pas une différence texte. Comparez les fichiers comme des chaînes et un réordonnancement de clés allume toute la page ; comparez les tableaux par index et insérer un enregistrement marque les cinquante autres comme changés.

Un patch a été livré au service de commandes, et tout ce que vous avez, ce sont deux réponses : cinquante enregistrements avant, cinquante-et-un après, avec un nouvel enregistrement inséré au milieu. L'outil de diff rapporte cinquante-et-un changements, un mur de rouge, et vous ne pouvez toujours pas dire quel enregistrement a réellement bougé.

L'outil n'est pas cassé. « Comparer » signifie trois choses différentes sur JSON, et la plupart des gens saisissent par défaut la moins utile.

Reproduire la fausse alerte : une insertion, cinquante-et-un changements

Trente lignes de Node 22 : construire cinquante commandes ({id, sku, qty}), puis en ajouter une à la fin et en insérer une au début, en comptant les différences avec un alignement index par index.

Opération Enregistrements Changements rapportés par position
Ajouter un à la fin 50 → 51 1
Insérer un au début 50 → 51 51

Même « un enregistrement de plus », cinquante-et-un fois le bruit. La raison est banale : quand vous comparez par index, le nouvel élément atterrit à la position 0 et chaque enregistrement existant se décale d'un cran. Position 0 contre position 0, position 1 contre position 1 — aucune paire n'est égale.

Construisez plutôt une Map clé par id et les mêmes données rapportent exactement une entrée : $[id=ORD-999] added.

Cette expérience donne la règle qui compte plus que toute recommandation d'outil : le fait que le tableau soit une séquence ordonnée ou un ensemble non ordonné décide de la comparaison à utiliser.

Trois façons dont un diff texte ment sur JSON

diff a.json b.json compare des caractères. JSON porte une structure de données. Ce désaccord produit trois types fiables de faux positifs.

1. Clés d'objet réordonnées

La section 4 de la RFC 8259 est explicite : un objet est « une collection non ordonnée de paires nom/valeur ». Ces deux documents sont la même valeur :

{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }

Un diff de lignes déclare les trois lignes changées. J'ai inversé l'ordre des clés d'un objet à dix clés, embelli les deux, et comparé ligne par ligne : les dix lignes signalées, alors qu'une comparaison structurelle dit qu'elles sont égales. Ratio de bruit 10:0.

Pourquoi l'ordre des clés change-t-il ? Changer de sérialiseur, exécuter un formateur avec des clés triées, reconstruire l'objet depuis un dictionnaire ou une table de hachage, passer entre runtimes de langages — l'un de ces cas suffit à faire paraître différentes des données identiques sur disque.

2. Espaces et indentation

{"a":1} et {\n "a": 1\n} se parsent en la même valeur. Un diff texte voit des retours à la ligne et des espaces et signale tout.

3. Orthographes différentes des nombres et échappements

1.0 et 1, 1e2 et 100, "\u00e9" et "é" — même valeur après parsing, texte différent après sérialisation. Celui-ci est plus bénin car il touche peu de lignes, mais en revue de configuration il suffit à vous faire croire qu'un nombre a changé.

Le cas inverse est ce que le texte ne peut pas voir et que la structure voit : 5 contre "5". Une paire de guillemets de plus, mais le type a dérivé — généralement un bug plutôt qu'une mise à jour. Une comparaison structurelle le rapporte comme modify sans hésiter.

Trois modes de comparaison, trois travaux

Mode Ce qui est comparé D'où vient le bruit Sortie Adapté à
Diff texte Lignes de caractères Ordre des clés, indentation, orthographe des nombres Numéros de ligne, rouge/vert Packs de traduction, fixtures à ordre de clés stable
Diff structurel Arbres parsés, alignés par chemin Tableaux alignés par index Chemins avec add/remove/modify Réponses d'API, configurations, webhooks
Diff sémantique Arbres plus clés métier Seulement un mauvais choix de clé Chemins avec détection de déplacement/insertion Listes d'enregistrements, commandes, inventaire

Le diff structurel résout la couche « ordre des clés et formatage ». Le diff sémantique va un cran plus loin et résout « identité des éléments de tableau ». La différence n'est pas la complexité d'implémentation ; c'est de savoir si vous acceptez de dire à l'outil quel champ est le passeport d'un enregistrement.

Les tableaux sont la ligne de partage

Posez une question : l'ordre de ce tableau est-il lui-même une donnée ?

  • L'ordre est une donnée : étapes de migration, classements de recherche, points de graphique, chaînes d'approbation. Un changement de position est un changement de comportement, donc la comparaison positionnelle est correcte.
  • L'ordre n'a pas de sens : tags, ensembles d'identifiants de permissions, lignes de commande, listes d'inventaire. Une insertion ne devrait pas affecter la façon dont les autres éléments sont jugés.

Quand l'ordre n'a pas de sens, indexez les enregistrements par une clé au lieu de les marteler par position :

// Match by id: an insert or delete reports one entry, other items stay put
function diffById(before, after, path = '$') {
  const out = [];
  const beforeMap = new Map(before.map((item) => [item.id, item]));
  const afterMap = new Map(after.map((item) => [item.id, item]));

  for (const [id, value] of afterMap) {
    if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
  }
  for (const [id, value] of beforeMap) {
    if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
    else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
      out.push({ path: `${path}[id=${id}]`, type: 'changed' });
    }
  }
  return out;
}

Les bibliothèques matures en font une option. jsondiffpatch utilise un LCS pour trouver la plus longue sous-séquence commune, mais les éléments objets dans les tableaux sont appariés par position par défaut — la documentation officielle insiste sur la nécessité de fournir une fonction objectHash pour les apparier par contenu. Les nouveaux utilisateurs concluent souvent que la bibliothèque est stupide ; en réalité ils n'ont jamais passé de clé.

Le choix de la clé compte aussi. id est le choix sûr. sku ne fonctionne que si les SKU sont uniques. Hacher tout l'objet transforme « un champ a changé » en « un supprimé plus un ajouté », ce qui est pire que comparer par position.

L'astuce terminal la plus rapide : normaliser avec jq

Si vous ne voulez pas de bibliothèque et avez juste besoin d'un coup d'œil, normalisez d'abord et différez ensuite. jq -S trie les clés d'objet récursivement et applique une indentation cohérente :

# Normalize, then compare line by line; key order and indentation noise disappear
diff -u <(jq -S . before.json) <(jq -S . after.json)

# When the array is genuinely unordered, sort by key before comparing
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
        <(jq -S '.items |= sort_by(.id)' after.json)

J'ai exécuté printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . et obtenu {"a":1,"arr":[1,2],"b":2} — clés triées, arr intact. Retenez ceci : -S ne trie que les clés d'objet, jamais les éléments de tableau. Soit vous triez les tableaux explicitement avec sort / sort_by, soit vous acceptez le bruit positionnel.

Deux pièges :

  • jq ne peut pas modifier sur place ; il écrit sur la sortie standard. Utilisez la substitution de processus (<(...)), ou écrivez un fichier temporaire, vérifiez, puis remplacez ;
  • -r supprime les guillemets des chaînes, donc la sortie peut contenir des retours à la ligne ou des caractères significatifs pour le shell. Mettez entre guillemets les données non fiables avant de les diriger plus loin.

Ce que fait /diff de ce site, et ce qu'il ne fait pas

L'outil intégré Comparaison JSON suit la même approche structurelle : les deux côtés sont parsés en arbres et alignés récursivement par chemin, produisant path + type(add/remove/modify) + before/after. L'ordre des clés d'objet n'affecte pas le résultat, et tout s'exécute dans le navigateur — les charges utiles collées ne sont jamais téléversées.

Ses tableaux, en revanche, sont alignés par index. C'est un compromis délibéré, pas un défaut :

  • Ajouter à la fin, modifier la même position ou remplacer en bloc donne une sortie propre ;
  • Insérer un enregistrement au début ou au milieu marque tout ce qui suit comme modify, et vous devez repérer que « en réalité un seul enregistrement est nouveau ».

Donc quand vous comparez des listes de commandes, le bon geste est de trier les deux côtés par id avant de coller. Une entrée triée fige le point d'insertion et réduit le bruit à une entrée. Aucune installation nécessaire, et cela couvre la plupart des cas quotidiens.

Si vous avez besoin de « générer un patch applicable » ou de « détecter des éléments déplacés », l'outil en page ne le fait pas. C'est la section suivante.

Transformer un diff en patch : deux formats, ne les mélangez pas

Les humains lisent les diffs ; les programmes appliquent des patchs. JSON a deux formats de patch aux sémantiques complètement différentes.

Dimension JSON Patch (RFC 6902) JSON Merge Patch (RFC 7396)
Forme Tableau d'opérations avec op, path, value Un objet JSON en forme de document cible
Tableaux Oui, par index, /items/0, /- pour ajouter Non, les tableaux sont remplacés en bloc
Supprimer une clé remove Seulement via null, qui ne peut pas supprimer un champ dont la valeur est légitimement null
Exprimer un déplacement move / copy Non
Adapté à Ressources contenant des tableaux, journaux d'audit, mises à jour atomiques Mises à jour partielles d'objets ordinaires, livraison de configuration

JSON Patch a une opération que les gens sautent : test.

[
  { "op": "test", "path": "/version", "value": 3 },
  { "op": "replace", "path": "/status", "value": "shipped" }
]

Si test échoue, tout le patch est rejeté. C'est du contrôle de concurrence optimiste intégré au protocole — aucun en-tête If-Match supplémentaire n'est nécessaire pour garantir « j'ai écrit seulement parce que j'ai vu la version 3 ». Pour un point de terminaison PATCH, cela vaut plus que n'importe quelle convention de documentation.

Les chemins utilisent JSON Pointer (RFC 6901) : ~0 échappe ~, ~1 échappe /. Les clés contenant des barres obliques (des types MIME comme clés, par exemple) doivent être échappées, sinon le pointeur atterrit ailleurs. Les détails vivent dans JSON Pointer et Patch.

Un problème pratique demeure : les patchs générés automatiquement depuis un diff adressent généralement les tableaux par index. Les index sont fragiles en production — une insertion concurrente et /items/3 ne pointe plus vers le même enregistrement. Soit vous limitez les patchs générés aux champs d'objet, soit vous validez les champs critiques avec test avant d'appliquer.

Mettre la comparaison en CI : assertez des valeurs, pas du texte

Trois règles suffisent :

  1. Assertissez des valeurs parsées dans les tests, jamais la sortie de JSON.stringify. En Jest, utilisez toEqual ; en Python, assert a == b après json.loads. L'ordre des clés et l'indentation ne participent pas.
  2. Mettez en liste blanche les champs dynamiques. requestId, timestamp, traceId diffèrent à chaque exécution ; sans exclusions le test est rouge en permanence. propertyFilter dans jsondiffpatch et exclude_paths dans deepdiff existent pour cela.
  3. Affichez les chemins en cas d'échec, pas tout le document. Personne ne lit 5 Mo de charge utile dans un journal ; $.data.items[id=ORD-042].qty: 2 → 3 localise le problème instantanément.

La dérive de configuration fonctionne de la même manière : passez jq -S sur les configurations de staging et de production, triez les tableaux par une clé métier, et tout ce qui reste est une vraie différence. Cette classe de problème — les mêmes données paraissant différentes selon l'environnement — vient généralement d'un contrat non défini, qui est le sujet de la conception de contrat d'API.

FAQ

L'ordre des clés compte-t-il comme une différence ?

Non. La RFC 8259 définit les objets comme des collections non ordonnées, donc {"a":1,"b":2} et {"b":2,"a":1} sont la même valeur. Tout outil qui rapporte un réordonnancement comme un changement fait de la comparaison texte, pas de la comparaison JSON.

Pourquoi insérer un enregistrement marque-t-il chaque entrée comme changée ?

Parce que l'outil aligne les tableaux index par index. Le nouvel élément est à la position 0, chaque enregistrement existant se décale d'un cran, et aucune paire de positions ne correspond. Triez ou indexez plutôt par une clé comme id avant de comparer au lieu de comparer positionnellement.

Les outils de comparaison en ligne téléversent-ils mes données ?

Cela dépend de l'implémentation. Les outils rendus côté serveur envoient la charge utile à un backend, où elle peut être journalisée ; les outils côté client parsent dans le navigateur et ne téléversent rien. Avant de comparer des configurations de production ou des charges utiles utilisateur, confirmez que la page calcule localement, ou déconnectez le réseau et réessayez.

Un résultat de diff peut-il être utilisé directement comme patch ?

Pas directement. Un diff dit où les choses diffèrent ; JSON Patch dit quelles opérations exécuter dans l'ordre. Une couche de conversion se trouve entre les deux. Les patchs générés tendent aussi à adresser les tableaux par index, ce qui casse sous concurrence — ajoutez des opérations test avant d'appliquer.

Comment comparer des fichiers de dizaines de mégaoctets ?

Passez d'abord en flux (jq -c écrit un enregistrement par ligne), ou extrayez seulement le sous-arbre qui vous intéresse, comme jq '.data.items'. Une comparaison récursive complète de dizaines de mégaoctets tient encore en mémoire ; au-delà de cent mégaoctets, découpez ou indexez par clé. Pour des chiffres concrets de mémoire et de temps, voir performances des grands JSON.

Faut-il trier les tableaux par id avant de comparer ?

Oui. L'outil intégré Comparaison JSON aligne les tableaux par index, donc insérer un enregistrement au début ou au milieu marque tout ce qui suit comme modify ; trier les deux côtés par id avant de coller fige le point d'insertion et réduit le bruit à une entrée. L'exception est quand l'ordre du tableau est lui-même une donnée — étapes de migration, classements de recherche, points de graphique — où la comparaison positionnelle est correcte et le tri faux.