StackPractices
beginner Por Mathias Paulenko

Comparar objetos JSON

Cómo comparar dos objetos JSON y encontrar las diferencias en Python, Java y JavaScript.

Temas: data

Descripción general

Comparar dos objetos JSON suena trivial hasta que lo intentas con datos anidados. {"a": {"b": 1}} y {"a": {"b": 2}} no son iguales, pero == solo te dice que difieren, no dónde ni cómo. Un diff de JSON de verdad recorre ambos árboles e informa de qué claves se añadieron, se eliminaron o cambiaron, a cualquier profundidad. Eso es lo que necesitas para tests de snapshot, detección de desviación de configuración, monitorización de contratos de API y auditorías de migración.

Esta receta muestra tres sabores en Python, JavaScript y Java: un informe categorizado (added / removed / updated), un parche reversible RFC 6902 y un diff recursivo sin dependencias.

Cuándo usarla

Usa esta receta cuando:

  • Valides que una respuesta de API REST coincide con un snapshot esperado
  • Detectes desviación de configuración entre archivos de entorno (staging frente a producción)
  • Audites una migración comparando exportaciones de antes y después
  • Construyas un flujo con applyPatch que reproduzca cambios entre sistemas

Si solo necesitas combinar dos objetos, consulta Merge JSON Files. Comparar y combinar son operaciones emparentadas pero opuestas.

Solución

Python

deepdiff da un informe categorizado sin configuración:

# pip install deepdiff
from deepdiff import DeepDiff

old = {"user": {"name": "Alice", "age": 30}, "roles": ["admin"]}
new = {"user": {"name": "Alice", "age": 31}, "roles": ["admin", "editor"]}

diff = DeepDiff(old, new)
print(diff)
# {'values_changed': {"root['user']['age']": {'old_value': 30, 'new_value': 31}},
#  'iterable_item_added': {"root['roles'][1]": 'editor'}}

Sin dependencias de terceros, un pequeño recorrido recursivo hace el mismo trabajo y es más fácil de personalizar:

def json_diff(old, new, path=""):
    """Devuelve {path: cambio} para dos estructuras tipo JSON."""
    changes = {}
    if old == new:
        return changes
    if isinstance(old, dict) and isinstance(new, dict):
        for key in old.keys() | new.keys():
            if key not in old:
                changes[f"{path}/{key}"] = {"added": new[key]}
            elif key not in new:
                changes[f"{path}/{key}"] = {"removed": old[key]}
            else:
                changes.update(json_diff(old[key], new[key], f"{path}/{key}"))
    elif isinstance(old, list) and isinstance(new, list):
        for i, (o, n) in enumerate(zip(old, new)):
            changes.update(json_diff(o, n, f"{path}/{i}"))
        for i in range(len(old), len(new)):
            changes[f"{path}/{i}"] = {"added": new[i]}
        for i in range(len(new), len(old)):
            changes[f"{path}/{i}"] = {"removed": old[i]}
    else:
        changes[path or "/"] = {"old": old, "new": new}
    return changes

JavaScript

fast-json-patch produce un parche RFC 6902 que puedes reproducir con applyPatch:

// npm install fast-json-patch
import * as jsonpatch from 'fast-json-patch';

const oldDoc = { user: { name: 'Alice', age: 30 }, roles: ['admin'] };
const newDoc = { user: { name: 'Alice', age: 31 }, roles: ['admin', 'editor'] };

const patch = jsonpatch.compare(oldDoc, newDoc);
console.log(patch);
// [
//   { op: 'replace', path: '/user/age', value: 31 },
//   { op: 'add',     path: '/roles/1',  value: 'editor' }
// ]

Para un informe sencillo de añadido/cambiado/eliminado, deep-object-diff es más ligero:

// npm install deep-object-diff
import { detailedDiff } from 'deep-object-diff';

console.log(detailedDiff(oldDoc, newDoc));
// {
//   added:   { roles: { '1': 'editor' } },
//   deleted: {},
//   updated: { user: { age: 31 } }
// }

Java

zjsonpatch emite el mismo formato RFC 6902 a través de Jackson:

// Maven: com.flipkart.zjsonpatch:zjsonpatch
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.flipkart.zjsonpatch.JsonDiff;

public class JsonDiffExample {
    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        JsonNode oldNode = mapper.readTree("{\"user\":{\"name\":\"Alice\",\"age\":30}}");
        JsonNode newNode = mapper.readTree("{\"user\":{\"name\":\"Alice\",\"age\":31}}");
        JsonNode patch = JsonDiff.asJson(oldNode, newNode);
        System.out.println(patch.toPrettyString());
        // [{"op":"replace","path":"/user/age","value":31}]
    }
}

Si quieres un informe sencillo sin la dependencia, recorre el árbol Jackson de forma recursiva. Un equals superficial sobre la raíz solo te dice que los árboles difieren:

public static Map<String, Object[]> diff(JsonNode a, JsonNode b,
                                         String path, Map<String, Object[]> out) {
    if (a.equals(b)) return out;

    if (a.isObject() && b.isObject()) {
        a.fields().forEachRemaining(e -> {
            JsonNode av = e.getValue();
            JsonNode bv = b.get(e.getKey());
            if (bv == null) out.put(path + "/" + e.getKey(), new Object[]{av, null});
            else diff(av, bv, path + "/" + e.getKey(), out);
        });
        b.fields().forEachRemaining(e -> {
            if (a.get(e.getKey()) == null)
                out.put(path + "/" + e.getKey(), new Object[]{null, e.getValue()});
        });
    } else if (a.isArray() && b.isArray()) {
        int common = Math.min(a.size(), b.size());
        for (int i = 0; i < common; i++) diff(a.get(i), b.get(i), path + "[" + i + "]", out);
        for (int i = common; i < a.size(); i++) out.put(path + "[" + i + "]", new Object[]{a.get(i), null});
        for (int i = common; i < b.size(); i++) out.put(path + "[" + i + "]", new Object[]{null, b.get(i)});
    } else {
        out.put(path.isEmpty() ? "/" : path, new Object[]{a, b});
    }
    return out;
}

Explicación

Comparar JSON es recorrer árboles. Ambos árboles se recorren nodo a nodo: las claves de objeto se comprueban en los dos lados, los elementos de array se comparan por índice y los escalares por igualdad. Lo que cambia entre librerías es la salida:

  • Informes categorizados (DeepDiff, deep-object-diff, los recorridos recursivos de arriba) responden a “¿qué cambió?”. Ideales para tests y auditorías.
  • Parches RFC 6902 (fast-json-patch, zjsonpatch) responden a “¿cómo convierto A en B?”: una lista de operaciones compacta y reproducible. Úsalos para APIs de sincronización y funciones de deshacer.

Un detalle: los arrays se comparan por posición. [1, 2] y [2, 1] producen un diff de dos cambios aunque el orden no importe en tu dominio. Ordena o normaliza primero cuando sea el caso.

flowchart diagram: ¿Qué necesitas?

Ejemplo práctico

Comparando una configuración de staging contra producción:

// staging.json
{ "db": { "host": "staging.db.local", "pool": 5 }, "cache": true, "features": ["search"] }

// prod.json
{ "db": { "host": "prod.db.local", "pool": 5 }, "cache": true, "features": ["search", "beta-ui"], "logLevel": "warn" }

El diff recursivo informa de tres cambios, no de un genérico “los archivos difieren”:

/db/host        → staging.db.local  →  prod.db.local
/features/1     → añadido: "beta-ui"
/logLevel       → añadido: "warn"

Eso sí es accionable: una diferencia de entorno esperada, una funcionalidad que alguien olvidó desactivar y un nivel de log que nunca se documentó. Un simple == no te habría contado nada de eso.

Variantes

TecnologíaLibreríaEnfoqueMejor para
PythondeepdiffDeepDiff(old, new)Informes categorizados, ignore_order, exclusiones
Pythonrecursiva stdlibjson_diff() de arribaCero dependencias, control total del formato de salida
JavaScriptfast-json-patchcompare(old, new)Parches RFC 6902, reversibles, compactos
JavaScriptdeep-object-diffdetailedDiff(old, new)División sencilla añadido/actualizado/eliminado
JavazjsonpatchJsonDiff.asJson(a, b)RFC 6902 vía Jackson, probado en producción
Javarecursión Jacksondiff() de arribaSin dependencia, informe personalizado

Lo que funciona

  • Normaliza antes de comparar: ordena claves y arrays cuando el orden no importe; DeepDiff acepta ignore_order=True
  • Excluye campos volátiles: timestamps, IDs de petición y contadores siempre difieren. Usa exclude_paths en deepdiff o poda las claves antes de comparar
  • Elige la salida según el trabajo: informe categorizado para auditorías, RFC 6902 cuando necesites reproducir el cambio
  • Compara subárboles en documentos enormes: un diff profundo de un payload de 10MB es lento; compara las secciones que te importan o hashea primero
  • Commitea los snapshots golden: los tests de snapshot necesitan archivos base en el control de versiones, revisados como código

Errores comunes

  • Comparar floats directamente: 1.0 y 1.00 serializan igual, pero el ruido de punto flotante (0.1 + 0.2) crea diffs fantasma. Redondea o usa una tolerancia
  • Ignorar el orden de arrays a propósito: [1, 2] y [2, 1] son JSONs distintos; decide de antemano si el orden importa y normaliza si no
  • Comparar JSON serializado: JSON.stringify conserva el orden de inserción, así que {"a":1,"b":2} y {"b":2,"a":1} comparan como cadenas distintas. Parsea primero y compara después
  • Tratar null y ausente como iguales: {"a": null} y {} son semánticamente distintos; asegúrate de que tu diff informa la clave como eliminada, no como cambiada
  • Loguear diffs en crudo: un diff estructural completo de una configuración grande es ilegible y puede filtrar secretos. Resume rutas y valores, y enmascara los campos sensibles primero

Cuándo NO comparar así

  • Solo necesitas igualdad: == (o assertEquals sobre nodos parseados) responde “igual o no” sin el coste del informe
  • El payload es enorme: los diffs profundos sobre documentos de varios MB queman CPU; compara hashes o checksums y solo difiere cuando no coincidan
  • Los cambios llegan en tiempo real: comparar cada payload en streaming es demasiado caro; valida incrementalmente o muestrea
  • Necesitas el resultado combinado: comparar te dice qué cambió, no combina los objetos. Para eso usa una receta de merge

Véase también

Preguntas frecuentes

¿Cómo ignoro campos concretos al comparar JSON?

Usa reglas de exclusión. DeepDiff soporta exclude_paths y exclude_regex_paths. fast-json-patch no filtra de forma nativa: preprocesa ambos objetos borrando las claves ignoradas antes de comparar. En los recorridos recursivos, salta la clave dentro del bucle.

¿Puedo comparar JSON ignorando el orden de los arrays?

Sí. DeepDiff tiene ignore_order=True. En JavaScript, ordena los arrays o conviértelos en conjuntos antes de comparar. Para los recorridos propios, ordena una copia de cada array con una clave estable antes de la comparación por índice.

¿Qué diferencia hay entre un informe de diff y un JSON Patch?

Un informe (added/removed/updated) es legible por personas y está pensado para inspección. Un JSON Patch (RFC 6902) es una lista de operaciones legible por máquina: add, remove, replace, move, copy, test, diseñada para reproducirse con applyPatch. Elige el informe para tests y auditorías; elige el parche para sincronización y deshacer.

¿Cómo genero un informe de diff legible?

El método pretty() de DeepDiff produce una salida legible. Para parches RFC 6902, mapea los códigos de operación a verbos: replace → "cambiado", add → "añadido", remove → "eliminado", e imprime cada uno con su ruta. Los recorridos recursivos te dejan formatear cada entrada como quieras.

¿Funciona el diff con arrays anidados dentro de objetos?

Sí, siempre que el algoritmo recorra ambos contenedores de forma recursiva. Los recorridos de arriba manejan anidamiento objeto-en-array y array-en-objeto a cualquier profundidad; lo que no pueden hacer es emparejar elementos de array reordenados por identidad, un problema más difícil que la mayoría de librerías solo resuelve aproximadamente.

¿Cómo gestiono null frente a una clave ausente?

Significan cosas distintas: {"a": null} asigna a a null explícitamente, mientras que {} ni siquiera tiene la clave. DeepDiff y los recorridos recursivos informan la clave ausente como añadida/eliminada y el null como un valor, que suele ser lo que quieres. Si tu dominio las trata como equivalentes, normaliza los campos null fuera antes de comparar.