JSON Toolkit

Cuando dos documentos JSON difieren: diff posicional, diff por id o normalizar primero

En resumen: Cincuenta registros se convierten en cincuenta y uno y el diff informa cincuenta y un cambios. Cubre los falsos positivos del diff de texto, la correspondencia de arrays por id, la normalización con jq y los parches aplicables.

Principiante~6 min de lecturaPublicado el 2026-10-30Actualizado el 2026-10-30
Portada de comparar dos documentos JSON

Conclusión: normaliza primero y compara después: usa jq -S o serialización estable para borrar el orden de claves, la sangría y la forma de escribir los números, ordena o indexa los arrays por una clave de negocio cuando el orden carezca de significado, y solo entonces convierte el diff en un parche.

Una diferencia JSON no es una diferencia de texto. Compara los archivos como cadenas y una reordenación de claves iluminará toda la página; compara arrays por índice e insertar un registro marcará los otros cincuenta como cambiados.

Se publicó un parche en el servicio de pedidos, y lo único que tienes son dos respuestas: cincuenta registros antes, cincuenta y uno después, con un registro nuevo insertado en medio. La herramienta de diff informa cincuenta y un cambios, una pared de rojo, y sigues sin poder decir qué registro se movió realmente.

La herramienta no está rota. "Comparar" significa tres cosas distintas en JSON, y la mayoría de la gente recurre por defecto a la menos útil.

Reproducir la falsa alarma: una inserción, cincuenta y un cambios

Treinta líneas de Node 22: construir cincuenta pedidos ({id, sku, qty}), luego añadir uno al final e insertar uno al principio, contando las diferencias con alineación índice por índice.

Operación Registros Cambios informados por posición
Añadir uno al final 50 → 51 1
Insertar uno al principio 50 → 51 51

El mismo "un registro extra", cincuenta y una veces el ruido. La razón es mundana: cuando comparas por índice, el elemento nuevo aterriza en la posición 0 y cada registro existente se desplaza un slot hacia abajo. Posición 0 frente a posición 0, posición 1 frente a posición 1: ningún par es igual.

Construye en cambio un Map indexado por id y los mismos datos informan exactamente una entrada: $[id=ORD-999] added.

Ese experimento te da la regla que importa más que cualquier recomendación de herramienta: si el array es una secuencia ordenada o un conjunto sin orden decide qué comparación debes usar.

Tres formas en que un diff de texto miente sobre JSON

diff a.json b.json compara caracteres. JSON lleva una estructura de datos. Ese desajuste produce tres tipos fiables de falsos positivos.

1. Claves de objeto reordenadas

La sección 4 de RFC 8259 es explícita: un objeto es "una colección no ordenada de pares nombre/valor". Estos dos documentos son el mismo valor:

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

Un diff de líneas dice que las tres líneas cambiaron. Invertí el orden de claves de un objeto de diez claves, embellecí ambos y comparé línea a línea: las diez líneas marcadas, mientras una comparación estructural dice que son iguales. Ratio de ruido 10:0.

¿Por qué cambia siquiera el orden de claves? Cambiar de serializador, ejecutar un formateador con claves ordenadas, reconstruir el objeto desde un diccionario o hash map, moverse entre runtimes de lenguajes: cualquiera de esos basta para que datos idénticos parezcan distintos en disco.

2. Espacios en blanco y sangría

{"a":1} y {\n "a": 1\n} se analizan al mismo valor. Un diff de texto ve saltos de línea y espacios y lo marca todo.

3. Distintas formas de escribir números y escapes

1.0 y 1, 1e2 y 100, "\u00e9" y "é": mismo valor después de analizar, texto distinto después de serializar. Este es más leve porque toca pocas líneas, pero en revisiones de configuración basta para hacerte creer que un número cambió.

El caso inverso es lo que el texto no puede ver y la estructura sí: 5 frente a "5". Un par de comillas extra, pero el tipo se desvió: normalmente un bug y no una actualización. Una comparación estructural lo informa como modify sin dudar.

Tres modos de comparación, tres trabajos

Modo Qué se compara De dónde viene el ruido Salida Encaja en
Diff de texto Líneas de caracteres Orden de claves, sangría, forma de escribir números Números de línea, rojo/verde Paquetes de localización, fixtures con orden de claves estable
Diff estructural Árboles analizados, alineados por ruta Arrays alineados por índice Rutas con add/remove/modify Respuestas de API, configuraciones, webhooks
Diff semántico Árboles más claves de negocio Solo una mala elección de clave Rutas con detección de move/insert Listas de registros, pedidos, inventario

El diff estructural resuelve la capa "orden de claves y formato". El diff semántico va un paso más allá y resuelve "identidad de los elementos de un array". La diferencia no es la complejidad de implementación; es si estás dispuesto a decirle a la herramienta qué campo es el pasaporte de un registro.

Los arrays son la línea divisoria

Haz una pregunta: ¿el orden de este array es en sí mismo dato?

  • El orden es dato: pasos de migración, rankings de búsqueda, puntos de un gráfico, cadenas de aprobación. Un cambio de posición es un cambio de comportamiento, así que la comparación posicional es correcta.
  • El orden no tiene significado: etiquetas, conjuntos de IDs de permisos, líneas de pedido, listas de inventario. Una inserción no debería afectar cómo se juzgan los demás elementos.

Cuando el orden carece de significado, indexa los registros por una clave en lugar de machacarlos por posición:

// Correspondencia por id: una inserción o eliminación informa una entrada, los demás elementos se quedan quietos
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;
}

Las bibliotecas maduras convierten esto en una opción. jsondiffpatch usa un LCS para encontrar la subsecuencia común más larga, pero los elementos de objeto dentro de arrays se emparejan por posición por defecto: la documentación oficial insiste en que debes proporcionar una función objectHash para emparejarlos por contenido. Los usuarios primerizos a menudo concluyen que la biblioteca es tonta; en realidad nunca le pasaron una clave.

Elegir la clave importa también. id es la opción segura. sku funciona solo cuando los SKU son únicos. Hashear el objeto entero convierte "un campo cambió" en "uno eliminado más uno añadido", lo que es peor que comparar por posición.

El truco de terminal más rápido: normalizar con jq

Si no quieres una biblioteca y solo necesitas echar un vistazo, normaliza primero y haz diff después. jq -S ordena las claves de los objetos de forma recursiva y aplica una sangría consistente:

# Normalizar y luego comparar línea a línea; el ruido de orden de claves y sangría desaparece
diff -u <(jq -S . before.json) <(jq -S . after.json)

# Cuando el array es genuinamente desordenado, ordena por clave antes de comparar
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
        <(jq -S '.items |= sort_by(.id)' after.json)

Ejecuté printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . y obtuve {"a":1,"arr":[1,2],"b":2}: claves ordenadas, arr intacto. Recuérdalo: -S ordena solo las claves de objeto, nunca los elementos de un array. Ordena los arrays explícitamente con sort / sort_by, o acepta el ruido posicional.

Dos trampas:

  • jq no puede editar in situ; escribe en la salida estándar. Usa sustitución de procesos (<(...)), o escribe un archivo temporal, verifica y luego reemplaza;
  • -r quita las comillas de las cadenas, así que la salida puede contener saltos de línea o caracteres significativos para el shell. Entrecomilla los datos no confiables antes de canalizarlos.

Qué hace el /diff de este sitio, y qué no

La herramienta de comparación JSON integrada sigue el mismo enfoque estructural: ambos lados se analizan en árboles y se alinean de forma recursiva por ruta, produciendo ruta + tipo(add/remove/modify) + antes/después. El orden de las claves de objeto no afecta el resultado, y todo se ejecuta en el navegador: los payloads pegados nunca se suben.

Sus arrays, sin embargo, se alinean por índice. Es un compromiso deliberado, no un defecto:

  • Añadir al final, modificar la misma posición o reemplazar por completo te da una salida limpia;
  • Insertar un registro al principio o en medio marca todo lo que va después como modify, y tienes que detectar que "en realidad solo un registro es nuevo".

Así que cuando comparas listas de pedidos, el movimiento correcto es ordenar ambos lados por id antes de pegar. La entrada ordenada fija el punto de inserción y colapsa el ruido a una sola entrada. No hace falta instalar nada, y cubre la mayoría de los casos del día a día.

Si necesitas "generar un parche aplicable" o "detectar elementos movidos", la herramienta en página no lo hace. Esa es la sección siguiente.

Convertir un diff en un parche: dos formatos, no los mezcles

Los humanos leen diffs; los programas aplican parches. JSON tiene dos formatos de parche con semánticas completamente distintas.

Dimensión JSON Patch (RFC 6902) JSON Merge Patch (RFC 7396)
Forma Array de operaciones con op, path, value Un objeto JSON con la forma del documento destino
Arrays Sí, por índice, /items/0, /- para añadir No, los arrays se reemplazan por completo
Eliminar una clave remove Solo mediante null, que no puede eliminar un campo cuyo valor es legítimamente null
Expresar un movimiento move / copy No
Encaja en Recursos que contienen arrays, logs de auditoría, actualizaciones atómicas Actualizaciones parciales de objetos planos, entrega de configuración

JSON Patch tiene una operación que la gente omite: test.

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

Si test falla, se rechaza el parche entero. Es concurrencia optimista incorporada al protocolo: no hace falta una cabecera If-Match extra para garantizar "solo escribí porque vi la versión 3". Para un endpoint PATCH, esto vale más que cualquier convención de documentación.

Las rutas usan JSON Pointer (RFC 6901): ~0 escapa ~, ~1 escapa /. Las claves que contienen barras (tipos MIME como claves, por ejemplo) deben escaparse, o el pointer aterrizará en otro sitio. Los detalles están en JSON Pointer y Patch.

Queda un problema práctico: los parches generados automáticamente a partir de un diff normalmente direccionan arrays por índice. Los índices son frágiles en producción: una inserción concurrente y /items/3 ya no apunta al mismo registro. O restringes los parches generados a campos de objeto, o validas los campos críticos con test antes de aplicarlos.

Lleva la comparación a CI: verifica valores, no texto

Tres reglas bastan:

  1. Verifica valores analizados en las pruebas, nunca la salida de JSON.stringify. En Jest usa toEqual; en Python assert a == b después de json.loads. El orden de claves y la sangría no participan.
  2. Pon en lista blanca los campos dinámicos. requestId, timestamp, traceId difieren en cada ejecución; sin exclusiones, la prueba estará permanentemente en rojo. propertyFilter en jsondiffpatch y exclude_paths en deepdiff existen para esto.
  3. Imprime rutas al fallar, no el documento entero. Nadie lee 5 MB de payload en un log; $.data.items[id=ORD-042].qty: 2 → 3 localiza el problema al instante.

La deriva de configuración funciona igual: aplica jq -S tanto a las configuraciones de staging como a las de producción, ordena los arrays por una clave de negocio, y lo que quede es una diferencia real. Esta clase de problema, los mismos datos pareciendo distintos entre entornos, suele ser un contrato no definido, que es de lo que trata el diseño de contratos de API.

FAQ

¿El orden de las claves cuenta como diferencia?

No. RFC 8259 define los objetos como colecciones no ordenadas, así que {"a":1,"b":2} y {"b":2,"a":1} son el mismo valor. Cualquier herramienta que informe una reordenación como cambio está haciendo comparación de texto, no comparación de JSON.

¿Por qué insertar un registro marca todas las entradas como cambiadas?

Porque la herramienta alinea los arrays índice por índice. El elemento nuevo se sitúa en la posición 0, cada registro existente se desplaza uno hacia abajo, y no coinciden dos posiciones. Ordena o indexa por una clave como id antes de comparar, en lugar de comparar posicionalmente.

¿Las herramientas de comparación en línea suben mis datos?

Depende de la implementación. Las herramientas renderizadas en servidor envían el payload a un backend, donde puede quedar registrado; las herramientas del lado del cliente analizan en el navegador y no suben nada. Antes de comparar configuraciones de producción o payloads de usuario, confirma que la página calcula localmente, o desconecta la red y vuelve a intentarlo.

¿El resultado de un diff puede usarse directamente como parche?

No directamente. Un diff dice dónde difieren las cosas; JSON Patch dice qué operaciones ejecutar en orden. Entre ambos se sitúa una capa de conversión. Los parches generados también tienden a direccionar arrays por índice, lo que se rompe bajo concurrencia: añade operaciones test antes de aplicar.

¿Cómo comparo archivos de decenas de megabytes?

Haz streaming primero (jq -c escribe un registro por línea), o extrae solo el subárbol que te interesa, como jq '.data.items'. Una comparación recursiva completa de decenas de megabytes todavía cabe en memoria; por encima de cien megabytes, fragmenta o indexa por clave. Para cifras concretas de memoria y tiempo, consulta rendimiento con JSON grande.

¿Debo ordenar los arrays por id antes de comparar?

Sí. La herramienta de comparación JSON integrada alinea los arrays por índice, así que insertar un registro al principio o en medio marca todo lo que va después como modify; ordenar ambos lados por id antes de pegar fija el punto de inserción y colapsa el ruido a una sola entrada. La excepción es cuando el orden del array es en sí mismo dato — pasos de migración, rankings de búsqueda, puntos de un gráfico — donde la comparación posicional es correcta y ordenar es incorrecto.