← Todos los artículos

Cómo comparar dos archivos JSON: algoritmos y herramientas

Un diff de texto plano pasa por alto reordenamientos de claves y ruido de espacios en blanco. Aprende cómo funciona un diff JSON adecuado: LCS por líneas, comparación de árbol semántica, normalización de claves y los compromisos de cada enfoque.

Dos respuestas JSON parecen casi idénticas, pero algo cambió entre despliegues y tus tests fallan. O estás revisando un pull request que toca un fichero de configuración grande y necesitas saber exactamente qué valores se movieron. Comparar ficheros JSON suena simple —— hasta que te das cuenta de que un diff de texto ingenuo trata el reordenamiento de claves como un cambio, y que las estructuras profundamente anidadas necesitan un enfoque más inteligente. Este artículo explica los algoritmos que hacen funcionar un diff de JSON como Dios manda.

Por qué un diff de texto plano se queda corto

La forma más sencilla de comparar dos ficheros es ejecutar diff o compararlos línea a línea. Para texto plano va bien. Para JSON se rompe al menos de dos formas.

Problema 1: orden de las claves

Por especificación, los objetos JSON son no ordenados. Los dos documentos siguientes son semánticamente idénticos:

// Documento A
{ "name": "Ada", "plan": "pro", "active": true }

// Documento B
{ "active": true, "name": "Ada", "plan": "pro" }

Un diff de texto línea a línea reportaría todas las líneas como cambiadas. Un diff JSON adecuado reporta cero diferencias.

Problema 2: ruido de formato

Indentación, espacios y si un array largo se escribe en una sola línea o en muchas son todos irrelevantes para los datos. Un diff de texto trata cada diferencia de espacios como un cambio.

La solución a ambos problemas es la misma: parsea primero, haz diff a la estructura, no al texto.

Paso 1 —— Parsear y normalizar

Antes de cualquier comparación, ambos documentos se parsean a objetos JavaScript con JSON.parse() y se vuelven a serializar con claves ordenadas e indentación consistente:

function normalise(json) {
  const value = JSON.parse(json);
  return JSON.stringify(sortKeys(value), null, 2);
}

function sortKeys(value) {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === 'object') {
    return Object.keys(value)
      .sort((a, b) => a.localeCompare(b))
      .reduce((acc, key) => {
        acc[key] = sortKeys(value[key]);
        return acc;
      }, {});
  }
  return value;
}

Tras la normalización, los documentos A y B de arriba producen la misma cadena:

{
  "active": true,
  "name": "Ada",
  "plan": "pro"
}

Ahora un diff de texto entre las dos cadenas normalizadas solo resaltará diferencias reales de datos, no ruido de formato ni de orden de claves.

Paso 2 —— Diff a nivel de línea con LCS

Con el texto normalizado, el problema se vuelve: dadas dos secuencias de líneas, encontrar el conjunto mínimo de cambios (inserciones y borrados) que transforma una en otra. Es el clásico problema del Longest Common Subsequence (LCS).

¿Qué es LCS?

El LCS de dos secuencias es la subsecuencia más larga que aparece en ambas, en el mismo orden, sin que tenga que ser contigua. Por ejemplo:

Before: ["  active: true", "  name: Ada",   "  plan: pro" ]
After:  ["  active: true", "  name: Ada",   "  plan: team"]

LCS:    ["  active: true", "  name: Ada"]   // 2 líneas en común

// Resultado:
//   same:    "  active: true"
//   same:    "  name: Ada"
//   deleted: "  plan: pro"
//   added:   "  plan: team"

El LCS nos da exactamente el diff que queremos: líneas que se mantuvieron, líneas que se borraron y líneas que se añadieron.

El algoritmo DP

LCS se resuelve con programación dinámica. Para dos arrays de longitud m y n construimos una tabla 2D donde dp[i][j] es la longitud del LCS de los primeros i elementos del array previo y los primeros j del array posterior:

// Rellena la tabla DP (de abajo a arriba)
for (let i = m - 1; i >= 0; i--) {
  for (let j = n - 1; j >= 0; j--) {
    dp[i][j] = before[i] === after[j]
      ? dp[i + 1][j + 1] + 1                    // líneas coinciden
      : Math.max(dp[i + 1][j], dp[i][j + 1]);   // toma la mejor rama
  }
}

// Recorre la tabla hacia atrás para recuperar las operaciones del diff
let i = 0, j = 0;
while (i < m && j < n) {
  if (before[i] === after[j]) {
    ops.push({ type: 'same', line: before[i++] }); j++;
  } else if (dp[i + 1][j] >= dp[i][j + 1]) {
    ops.push({ type: 'del', line: before[i++] });
  } else {
    ops.push({ type: 'add', line: after[j++] });
  }
}

Complejidad temporal: O(m × n). Complejidad espacial: O(m × n). Para documentos JSON típicos es bastante rápido. Para documentos muy grandes (digamos, más de 2000 líneas cada uno), la tabla puede consumir memoria considerable —— a esa escala vale la pena cambiar al algoritmo de diff de Myers, que se ejecuta en tiempo O(n + d²) donde d es el número de diferencias.

Optimización de memoria: typed arrays

Un array JavaScript 2D plano de números tiene sobrecarga sustancial por elemento. Usar un Int32Array plano reduce la memoria aproximadamente 8× y mejora la localidad de caché:

const W  = n + 1;
const dp = new Int32Array((m + 1) * W); // buffer plano

// Accede dp[i][j] como dp[i * W + j]
dp[i * W + j] = before[i] === after[j]
  ? dp[(i + 1) * W + (j + 1)] + 1
  : Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)]);

Para un documento de 500 líneas, esto pasa la tabla de ~2 MB de objetos en el heap a ~1 MB de memoria typed contigua.

Paso 3 —— Emparejar cambios adyacentes en filas «modificadas»

La salida cruda del LCS solo conoce borrados y adiciones. Pero en una vista de diff lado a lado, queda mucho mejor mostrar una línea borrada emparejada con la línea añadida que la sustituyó —— una fila «modificada»:

// Operaciones crudas del LCS:
del  "  plan: pro"
add  "  plan: team"

// Tras el emparejamiento:
modified  left: "  plan: pro"   right: "  plan: team"

El algoritmo de emparejamiento reúne bloques consecutivos de operaciones del y add y las cruza. Los borrados sin emparejar reciben un placeholder vacío a la derecha; las adiciones sin emparejar, uno a la izquierda:

// Reúne un bloque consecutivo de del/add
const dels = [], adds = [];
while (ops[i].type !== 'same') {
  if (ops[i].type === 'del') dels.push(ops[i].line);
  else                        adds.push(ops[i].line);
  i++;
}

// Empareja en pares modificados
const pairs = Math.min(dels.length, adds.length);
for (let k = 0; k < pairs; k++)
  rows.push({ type: 'modified', left: dels[k], right: adds[k] });

// Borrados restantes sin emparejar
for (let k = pairs; k < dels.length; k++)
  rows.push({ type: 'deleted', left: dels[k] });

// Adiciones restantes sin emparejar
for (let k = pairs; k < adds.length; k++)
  rows.push({ type: 'added', right: adds[k] });

Paso 4 —— Diff semántico para los conteos del resumen

El diff de líneas te dice qué texto cambió. Un diff semántico te dice qué campos cambiaron y cómo —— útil para un resumen como «3 añadidos, 1 eliminado, 2 modificados».

El diff semántico recorre recursivamente ambos objetos parseados al mismo tiempo:

function diffValue(key, path, before, after) {
  if (before === undefined) return { status: 'added',   after  };
  if (after  === undefined) return { status: 'removed', before };

  if (isObject(before) && isObject(after)) {
    const keys     = union(Object.keys(before), Object.keys(after)).sort();
    const children = keys.map(k =>
      diffValue(k, path + '.' + k, before[k], after[k])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  if (Array.isArray(before) && Array.isArray(after)) {
    const len      = Math.max(before.length, after.length);
    const children = Array.from({ length: len }, (_, i) =>
      diffValue(i, path + '[' + i + ']', before[i], after[i])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  return deepEqual(before, after)
    ? { status: 'unchanged', before, after }
    : { status: 'changed',   before, after };
}

Recorrer el árbol resultante y contar nodos hoja por estado da los números del resumen. A diferencia del diff de líneas, el diff semántico es agnóstico al orden de las claves por construcción —— compara valores en la misma ruta de clave, sin importar el orden en que aparezcan en el JSON.

Poniéndolo todo junto

Una herramienta de diff JSON completa encadena estos pasos:

function jsonDiff(leftText, rightText) {
  // 1. Parsear
  const leftVal  = JSON.parse(leftText);
  const rightVal = JSON.parse(rightText);

  // 2. Diff semántico → conteos de resumen
  const summary = summarise(diffValue('root', '
#x27;, leftVal, rightVal)); // 3. Normalizar (ordenar claves, indentación consistente) const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2); const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2); // 4. Diff de líneas con LCS const rows = buildLineDiff(leftNorm, rightNorm); return { summary, rows }; }

Deep-Equal frente a diff estructural

Un atajo común es comparar dos documentos JSON con deepEqual (el assert.deepStrictEqual de Node, isEqual de Lodash) —— rápido, pero solo booleano. Te dice que los documentos difieren; no te dice dónde ni cómo.

Un diff estructural recorre ambos árboles y produce un informe (conteos, rutas, valores antes/después) y, opcionalmente, un JSON Patch. Usa deepEqual cuando la respuesta sea sí/no —— en tests, en invalidación de cache. Usa un diff estructural cuando una persona necesita actuar sobre las diferencias.

Generar un JSON Patch a partir del diff

Una vez tienes un árbol de diff semántico, el mismo recorrido que cuenta nodos añadidos/eliminados/cambiados puede emitir un JSON Patch (RFC 6902) —— una lista portable de operaciones add / remove / replace que convierte el documento «antes» en el «después». Así es como tomas un diff y lo envías como cuerpo de una petición HTTP PATCH, o lo aplicas más tarde en otro sitio.

function toJsonPatch(node, pointer = '') {
  const ops = [];
  if (node.status === 'added')   ops.push({ op: 'add',     path: pointer, value: node.after });
  if (node.status === 'removed') ops.push({ op: 'remove',  path: pointer });
  if (node.status === 'changed' && !node.children) {
    ops.push({ op: 'replace', path: pointer, value: node.after });
  }
  for (const child of node.children ?? []) {
    const seg = String(child.key).replace(/~/g, '~0').replace(/\//g, '~1');
    ops.push(...toJsonPatch(child, pointer + '/' + seg));
  }
  return ops;
}

Los segmentos de ruta siguen las reglas de JSON Pointer (RFC 6901) —— ojo con los escapes ~0 / ~1 para ~ y / en las claves. Librerías como fast-json-patch generan lo mismo si prefieres no escribirlo a mano. Al enviar el patch por HTTP usa el content type application/json-patch+json; para la alternativa «overlay» más simple en la que null significa borrar, ver JSON Patch vs JSON Merge Patch.

Casos límite que conviene conocer

  • Reordenamiento de elementos del array —— el diff semántico compara arrays por posición ([0] contra [0]), así que si un array está ordenado de forma distinta entre versiones, cada elemento puede aparecer como «cambiado» aunque los datos sean los mismos. Manejarlo bien requiere LCS a nivel de array, lo que añade bastante complejidad.
  • Documentos muy grandes —— la tabla LCS O(m × n) puede agotar memoria con documentos de decenas de miles de líneas. Heurística práctica: si m × n supera un umbral (digamos 2 millones), cae a mostrar todas las líneas como cambiadas en vez de calcular el diff completo.
  • Precisión numérica —— JSON.parse() convierte todos los números a doubles IEEE 754. Los enteros muy grandes (más allá de 2⁵³) pierden precisión silenciosamente, así que dos documentos que difieren solo en los últimos dígitos de un entero grande pueden compararse como iguales tras el parseo.

Preguntas frecuentes

¿Cómo comparo dos ficheros JSON?

Parsea ambos, normalízalos (ordena claves, indentación consistente) y luego haz un diff estructural en vez de un diff de texto plano —— o pega ambos en JSON Diff, que hace esto y muestra una vista lado a lado con códigos de color.

¿Por qué un diff de texto plano no funciona con JSON?

Porque los objetos JSON son no ordenados y los espacios son insignificantes. Un diff de texto marca claves reordenadas y reformatos como cambios; un diff consciente de JSON ignora ambos y reporta solo diferencias reales de datos.

¿Qué es un diff JSON semántico?

Uno que recorre las dos estructuras parseadas y compara valores en la misma ruta de clave sin importar el orden, produciendo conteos como «3 añadidos, 1 eliminado, 2 cambiados». Por construcción es agnóstico al orden de las claves.

¿Comparar JSON puede perder precisión numérica?

Sí —— JSON.parse() convierte los números a doubles IEEE 754, así que los enteros más allá de 2⁵³ pueden compararse iguales aun cuando difieren en los últimos dígitos. Las reglas de canonicalización de RFC 8785 abordan cuestiones relacionadas.

Prueba la herramienta JSON Diff

JSON Diff en fixjson.org implementa todo lo anterior: parsea ambos documentos, normaliza con claves ordenadas, ejecuta el diff de líneas con LCS y muestra una vista lado a lado con código de color para adiciones, eliminaciones y modificaciones —— más una fila de resumen con el número de campos añadidos, eliminados, cambiados y sin cambios. También soporta documentos YAML. Todo se ejecuta en tu navegador; no se envían datos a ningún servidor.