Duas respostas JSON parecem quase idênticas, mas algo mudou entre deploys e seus testes estão falhando. Ou você está revisando um pull request que mexe num arquivo de configuração grande e precisa saber exatamente quais valores mudaram. Comparar arquivos JSON parece simples —— até você perceber que um diff de texto ingênuo trata reordenação de chaves como mudança e que estruturas profundamente aninhadas precisam de uma abordagem mais inteligente. Este artigo explica os algoritmos que fazem um diff JSON de verdade funcionar.
Por que um diff de texto puro não basta
A forma mais simples de comparar dois arquivos é rodar diff ou compará-los linha a linha. Para texto puro funciona bem. Para JSON quebra de pelo menos duas formas.
Problema 1: ordem das chaves
Pela especificação, objetos JSON são não ordenados. Os dois documentos a seguir são semanticamente idênticos:
// Documento A
{ "name": "Ada", "plan": "pro", "active": true }
// Documento B
{ "active": true, "name": "Ada", "plan": "pro" }Um diff de texto linha a linha reportaria toda linha como alterada. Um diff JSON de verdade reporta zero diferenças.
Problema 2: ruído de formatação
Indentação, espaços e se um array longo está numa linha só ou em várias —— tudo isso é irrelevante para os dados. Um diff de texto trata cada diferença de espaço como mudança.
A solução para os dois problemas é a mesma: parseie primeiro, depois faça diff da estrutura, não do texto.
Passo 1 —— Parsear e normalizar
Antes de qualquer comparação, os dois documentos são parseados para objetos JavaScript com JSON.parse() e re-serializados com chaves ordenadas e indentação 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;
}Depois da normalização, os documentos A e B acima produzem a mesma string:
{
"active": true,
"name": "Ada",
"plan": "pro"
}Agora um diff de texto entre as duas strings normalizadas só destacará diferenças reais de dados, sem ruído de formatação ou de ordem de chaves.
Passo 2 —— Diff em nível de linha com LCS
Com texto normalizado, o problema vira: dadas duas sequências de linhas, encontrar o conjunto mínimo de mudanças (inserções e remoções) que transforma uma na outra. É o clássico problema da Longest Common Subsequence (LCS).
O que é LCS?
A LCS de duas sequências é a maior subsequência que aparece em ambas, na mesma ordem, sem precisar ser contígua. Por exemplo:
Before: [" active: true", " name: Ada", " plan: pro" ]
After: [" active: true", " name: Ada", " plan: team"]
LCS: [" active: true", " name: Ada"] // 2 linhas em comum
// Resultado:
// same: " active: true"
// same: " name: Ada"
// deleted: " plan: pro"
// added: " plan: team"A LCS dá exatamente o diff que queremos: linhas que permaneceram, linhas removidas e linhas adicionadas.
O algoritmo DP
LCS é resolvido com programação dinâmica. Para dois arrays de tamanho m e n, construímos uma tabela 2D onde dp[i][j] é o tamanho da LCS dos primeiros i elementos do array de antes e dos primeiros j do array de depois:
// Preenche a tabela DP (de baixo pra cima)
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 // linhas batem
: Math.max(dp[i + 1][j], dp[i][j + 1]); // pega o ramo melhor
}
}
// Retraça a tabela para recuperar as operações do 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++] });
}
}Complexidade de tempo: O(m × n). Complexidade de espaço: O(m × n). Para documentos JSON típicos é rápido o bastante. Para documentos muito grandes (digamos, mais de 2000 linhas cada), a tabela pode consumir memória significativa —— nessa escala vale trocar para o algoritmo de Myers, que roda em O(n + d²) onde d é o número de diferenças.
Otimização de memória: typed arrays
Um array 2D de números em JavaScript puro tem overhead substancial por elemento. Usar um Int32Array plano reduz memória em ~8× e melhora a localidade de cache:
const W = n + 1;
const dp = new Int32Array((m + 1) * W); // buffer plano
// Acessa 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 um documento de 500 linhas isso traz a tabela de ~2 MB de objetos no heap para ~1 MB de memória tipada contígua.
Passo 3 —— Parear mudanças adjacentes em linhas „modificadas“
A saída crua da LCS só conhece remoções e adições. Mas numa visão de diff lado a lado, fica muito melhor mostrar uma linha removida pareada com a linha adicionada que a substituiu —— uma linha „modificada“:
// Ops cruas da LCS:
del " plan: pro"
add " plan: team"
// Após o pareamento:
modified left: " plan: pro" right: " plan: team" O algoritmo de pareamento coleta blocos consecutivos de operações del e add e as combina como um zíper. Remoções sem par recebem placeholder vazio à direita; adições sem par recebem placeholder vazio à esquerda:
// Coleta um bloco 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++;
}
// Combina em 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] });
// Remoções restantes sem par
for (let k = pairs; k < dels.length; k++)
rows.push({ type: 'deleted', left: dels[k] });
// Adições restantes sem par
for (let k = pairs; k < adds.length; k++)
rows.push({ type: 'added', right: adds[k] });Passo 4 —— Diff semântico para os contadores do resumo
O diff de linhas te diz qual texto mudou. Um diff semântico te diz quais campos mudaram e como —— útil para um resumo do tipo „3 adicionados, 1 removido, 2 alterados“.
O diff semântico percorre recursivamente os dois objetos parseados ao mesmo tempo:
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 };
}Percorrer a árvore resultante e contar folhas por status dá os números do resumo. Diferente do diff de linhas, o diff semântico é por construção agnóstico à ordem das chaves —— ele compara valores no mesmo caminho de chave, sem se importar com a ordem em que aparecem no JSON.
Juntando tudo
Uma ferramenta completa de diff JSON encadeia esses passos:
function jsonDiff(leftText, rightText) {
// 1. Parsear
const leftVal = JSON.parse(leftText);
const rightVal = JSON.parse(rightText);
// 2. Diff semântico → contadores do resumo
const summary = summarise(diffValue('root', '#x27;, leftVal, rightVal));
// 3. Normalizar (ordenar chaves, indentação consistente)
const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2);
const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2);
// 4. Diff de linhas com LCS
const rows = buildLineDiff(leftNorm, rightNorm);
return { summary, rows };
}Deep-Equal vs diff estrutural
Um atalho comum é comparar dois documentos JSON com deepEqual (o assert.deepStrictEqual do Node, o isEqual do Lodash) —— rápido, mas só booleano. Te diz que os documentos diferem; não te diz onde nem como.
Um diff estrutural percorre as duas árvores e produz um relatório (contadores, caminhos, valores antes/depois) e, opcionalmente, um JSON Patch. Use deepEqual quando a resposta é sim/não —— em testes, em invalidação de cache. Use um diff estrutural quando alguém precisa agir sobre as diferenças.
Gerar um JSON Patch a partir do diff
Tendo a árvore do diff semântico, a mesma travessia que conta nós adicionados/removidos/alterados pode emitir um JSON Patch (RFC 6902) —— uma lista port átil de operações add / remove / replace que transforma o documento „antes“ no „depois“. É assim que você pega um diff e envia como corpo de uma requisição HTTP PATCH, ou aplica depois em outro lugar.
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;
} Os segmentos de caminho seguem as regras de JSON Pointer (RFC 6901) —— note os escapes ~0 / ~1 para ~ e / nas chaves. Bibliotecas como fast-json-patch geram a mesma coisa se você preferir não escrever do zero. Quando o patch é enviado por HTTP use o content type application/json-patch+json; para a alternativa „overlay“ mais simples em que null significa apagar, veja JSON Patch vs JSON Merge Patch.
Casos de borda que vale conhecer
- Reordenação de elementos de array —— o diff semântico compara arrays por posição (
[0]vs[0]), então se um array estiver ordenado de forma diferente entre versões, todo elemento pode aparecer como „alterado“ mesmo que os dados sejam iguais. Tratar isso bem exige LCS no nível do array, o que adiciona complexidade significativa. - Documentos muito grandes —— a tabela LCS O(m × n) pode esgotar memória em documentos com dezenas de milhares de linhas. Heurística prática: se m × n ultrapassar um limiar (digamos, 2 milhões), recue para exibir todas as linhas como alteradas em vez de calcular o diff completo.
- Precisão numérica ——
JSON.parse()converte todos os números em doubles IEEE 754. Inteiros muito grandes (acima de 2⁵³) perdem precisão silenciosamente, então dois documentos que diferem só nos últimos dígitos de um inteiro grande podem comparar como iguais depois do parse.
Perguntas frequentes
Como comparo dois arquivos JSON?
Parseie os dois, normalize (ordene chaves, indentação consistente) e rode um diff estrutural em vez de um diff de texto puro —— ou cole os dois no JSON Diff, que faz isso e mostra uma visão lado a lado com cores.
Por que um diff de texto puro não funciona para JSON?
Porque objetos JSON são não ordenados e espaços são insignificantes. Um diff de texto marca chaves reordenadas e reformatação como mudanças; um diff ciente de JSON ignora ambos e só reporta diferenças reais de dados.
O que é um diff JSON semântico?
Um diff que percorre as duas estruturas parseadas e compara valores no mesmo caminho de chave independente da ordem, produzindo contadores como „3 adicionados, 1 removido, 2 alterados“. Agnóstico à ordem das chaves por construção.
Comparar JSON pode perder precisão numérica?
Pode —— JSON.parse() converte números em doubles IEEE 754, então inteiros acima de 2⁵³ podem comparar como iguais mesmo quando os últimos dígitos diferem. As regras de canonicalização em RFC 8785 tratam questões relacionadas.
Experimente a ferramenta JSON Diff
JSON Diff no fixjson.org implementa tudo descrito acima: parseia os dois documentos, normaliza com chaves ordenadas, roda o diff de linhas LCS e mostra uma visão lado a lado com adições, remoções e modificações coloridas —— mais uma linha de resumo mostrando os contadores de campos adicionados, removidos, alterados e inalterados. Também suporta documentos YAML. Tudo roda no seu navegador; nenhum dado é enviado a nenhum servidor.
- JSON Diff —— compare dois documentos JSON ou YAML lado a lado
- JSON Patch vs JSON Merge Patch —— representa as diferenças entre dois documentos como uma atualização
- RFC 8785: JSON Canonicalization (JCS) —— como ordenar chaves e normalizar números produz um hash estável de qualquer documento JSON
- JSON Fix —— repare JSON inválido antes de fazer o diff
- Lidando com JSON quebrado em JavaScript —— o que fazer quando um dos seus documentos não faz parse