<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Anjaneyulu Sunkara]]></title><description><![CDATA[Anjaneyulu Sunkara]]></description><link>https://anji007.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 20:40:47 GMT</lastBuildDate><atom:link href="https://anji007.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your JSON Diff Says Everything Changed. Here's What Actually Changed.]]></title><description><![CDATA[Learn how to compare JSON structurally in JavaScript, ignore key order, match array records by ID, and detect value and type changes.]]></description><link>https://anji007.hashnode.dev/your-json-diff-says-everything-changed-here-s-what-actually-changed</link><guid isPermaLink="true">https://anji007.hashnode.dev/your-json-diff-says-everything-changed-here-s-what-actually-changed</guid><category><![CDATA[json]]></category><category><![CDATA[api]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[Anjaneyulu Sunkara]]></dc:creator><pubDate>Thu, 29 Oct 2026 05:32:41 GMT</pubDate><content:encoded><![CDATA[<p>You have two responses from the same endpoint, say from staging and production, or from before and after a deploy, and you want to know what actually changed. This walks through why a text diff gives you a misleading answer, and how a structural comparison avoids it, with a short function you can run and adapt.</p>
<h2>The example</h2>
<p><strong>A (before)</strong></p>
<pre><code class="language-json">{
  "orderId": "A-1001",
  "status": "paid",
  "updatedAt": "2026-10-20T09:30:00Z",
  "items": [
    { "sku": "BK-001", "qty": 2, "price": 12.5 },
    { "sku": "PN-114", "qty": 1, "price": 3.99 }
  ]
}
</code></pre>
<p><strong>B (after)</strong></p>
<pre><code class="language-json">{
  "status": "paid",
  "orderId": "A-1001",
  "items": [
    { "sku": "PN-114", "qty": 1, "price": "3.99" },
    { "sku": "BK-001", "qty": 3, "price": 12.5 }
  ],
  "updatedAt": "2026-10-21T14:02:11Z"
}
</code></pre>
<p>Between A and B, the keys were reordered, the two items swapped places, and the timestamp moved. If we're checking the business data while ignoring timestamps and item order, two changes matter:</p>
<ol>
<li><code>BK-001</code>'s quantity went from <code>2</code> to <code>3</code>.</li>
<li><code>PN-114</code>'s price became the <strong>string</strong> <code>"3.99"</code> instead of the number <code>3.99</code>, even though it looks the same when printed. That type change can break validation, fail strict equality checks, or cause unexpected results in calculations: <code>"3.99" * 2</code> is still <code>7.98</code>, but <code>"3.99" + 1</code> is the string <code>"3.991"</code>.</li>
</ol>
<h2>What a text diff reports</h2>
<p><code>diff a.json b.json</code> flags 5 of A's 11 lines and 6 of B's:</p>
<pre><code class="language-text">2d1
&lt;   "orderId": "A-1001",
4c3
&lt;   "updatedAt": "2026-10-20T09:30:00Z",
---
&gt;   "orderId": "A-1001",
6,8c5,8
&lt;     { "sku": "BK-001", "qty": 2, "price": 12.5 },
&lt;     { "sku": "PN-114", "qty": 1, "price": 3.99 }
&lt;   ]
---
&gt;     { "sku": "PN-114", "qty": 1, "price": "3.99" },
&gt;     { "sku": "BK-001", "qty": 3, "price": 12.5 }
&gt;   ],
&gt;   "updatedAt": "2026-10-21T14:02:11Z"
</code></pre>
<p>The type change is in there, but you have to spot that one pair of quotes among the moved lines.</p>
<p>Sorting keys before diffing is a common next step. It fixes the key order, but the swapped array items still show up as a block of removed and added lines (excerpt):</p>
<pre><code class="language-diff">   "items": [
     {
-      "price": 12.5,
-      "qty": 2,
-      "sku": "BK-001"
-    },
-    {
-      "price": 3.99,
+      "price": "3.99",
       "qty": 1,
       "sku": "PN-114"
+    },
+    {
+      "price": 12.5,
+      "qty": 3,
+      "sku": "BK-001"
     }
   ],
</code></pre>
<p>Text tools compare lines. To get a useful answer you need to compare values.</p>
<h2>A structural comparison</h2>
<p>The function below parses nothing itself. You give it two values from <code>JSON.parse</code>, and it returns a list of changes with their paths. It makes three decisions:</p>
<ul>
<li><strong>Objects are compared by key</strong>, so key order never matters.</li>
<li><strong>Arrays are matched by an id field</strong> when you configure one for that path, and by position otherwise.</li>
<li><strong>A value whose type changes</strong> (number to string, object to <code>null</code>) is reported as <code>type</code>, separately from an ordinary <code>changed</code> value.</li>
</ul>
<pre><code class="language-js">// Structural JSON comparison: key order never matters, arrays are matched
// by an id field when one is configured for that path, otherwise by position.
const kind = (v) =&gt; (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);

function compare(a, b, opts = {}, path = '$', out = []) {
  const { ignore = [], arrayKeys = {} } = opts;
  const name = path.split(/[.[]/).pop();
  if (ignore.includes(name)) return out;

  const ka = kind(a), kb = kind(b);
  if (ka !== kb) {
    out.push({ path, change: 'type', from: a, to: b });
  } else if (ka === 'object') {
    for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
      const p = `${path}.${key}`;
      if (!Object.hasOwn(b, key)) out.push({ path: p, change: 'removed', from: a[key] });
      else if (!Object.hasOwn(a, key)) out.push({ path: p, change: 'added', to: b[key] });
      else compare(a[key], b[key], opts, p, out);
    }
  } else if (ka === 'array') {
    const id = arrayKeys[path];
    const index = (list) =&gt; {
      const map = new Map();
      list.forEach((item, i) =&gt; {
        const k = id ? item?.[id] : i;
        if (k === undefined) throw new Error(`${path}[${i}] has no ${id}`);
        if (map.has(k)) throw new Error(`${path}: duplicate ${id} ${k}`);
        map.set(k, item);
      });
      return map;
    };
    const ma = index(a), mb = index(b);
    for (const k of new Set([...ma.keys(), ...mb.keys()])) {
      const p = id ? `${path}[${id}=${JSON.stringify(k)}]` : `${path}[${k}]`;
      if (!mb.has(k)) out.push({ path: p, change: 'removed', from: ma.get(k) });
      else if (!ma.has(k)) out.push({ path: p, change: 'added', to: mb.get(k) });
      else compare(ma.get(k), mb.get(k), opts, p, out);
    }
  } else if (a !== b) {
    out.push({ path, change: 'changed', from: a, to: b });
  }
  return out;
}
</code></pre>
<p>The key checks use <code>Object.hasOwn</code> rather than <code>in</code>, because <code>in</code> also finds inherited properties: with <code>in</code>, a key named <code>toString</code> that exists only in A would be compared against <code>Object.prototype.toString</code> instead of being reported as removed. JSON objects can contain keys like that.</p>
<p>To run the examples, paste the function into a browser console or a Node REPL, then assign the two documents above to <code>a</code> and <code>b</code>. JSON objects are also valid JavaScript object literals, so <code>const a = { "orderId": "A-1001", … }</code> with the full text of A works, and the same for B.</p>
<h2>Results on the example</h2>
<p><strong>By position, nothing ignored:</strong> <code>compare(a, b)</code> returns 7 changes. Key order no longer counts, but because the items swapped places, every field of both items looks different:</p>
<table>
<thead>
<tr>
<th>path</th>
<th>change</th>
<th>from</th>
<th>to</th>
</tr>
</thead>
<tbody><tr>
<td><code>$.updatedAt</code></td>
<td>changed</td>
<td><code>"2026-10-20T09:30:00Z"</code></td>
<td><code>"2026-10-21T14:02:11Z"</code></td>
</tr>
<tr>
<td><code>$.items[0].sku</code></td>
<td>changed</td>
<td><code>"BK-001"</code></td>
<td><code>"PN-114"</code></td>
</tr>
<tr>
<td><code>$.items[0].qty</code></td>
<td>changed</td>
<td><code>2</code></td>
<td><code>1</code></td>
</tr>
<tr>
<td><code>$.items[0].price</code></td>
<td>type</td>
<td><code>12.5</code></td>
<td><code>"3.99"</code></td>
</tr>
<tr>
<td><code>$.items[1].sku</code></td>
<td>changed</td>
<td><code>"PN-114"</code></td>
<td><code>"BK-001"</code></td>
</tr>
<tr>
<td><code>$.items[1].qty</code></td>
<td>changed</td>
<td><code>1</code></td>
<td><code>3</code></td>
</tr>
<tr>
<td><code>$.items[1].price</code></td>
<td>changed</td>
<td><code>3.99</code></td>
<td><code>12.5</code></td>
</tr>
</tbody></table>
<p>Even the type change is reported against the wrong item.</p>
<p><strong>Matched by <code>sku</code>, timestamp ignored:</strong></p>
<pre><code class="language-js">compare(a, b, { ignore: ['updatedAt'], arrayKeys: { '$.items': 'sku' } });
</code></pre>
<table>
<thead>
<tr>
<th>path</th>
<th>change</th>
<th>from</th>
<th>to</th>
</tr>
</thead>
<tbody><tr>
<td><code>$.items[sku="BK-001"].qty</code></td>
<td>changed</td>
<td><code>2</code></td>
<td><code>3</code></td>
</tr>
<tr>
<td><code>$.items[sku="PN-114"].price</code></td>
<td>type</td>
<td><code>3.99</code></td>
<td><code>"3.99"</code></td>
</tr>
</tbody></table>
<p>Those are exactly the two real changes.</p>
<h2>Choosing how to match each array</h2>
<p>No single rule fits every array, which is why <code>arrayKeys</code> is set per path:</p>
<table>
<thead>
<tr>
<th>The array holds</th>
<th>Match by</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Records with an id (<code>id</code>, <code>sku</code>, <code>uuid</code>)</td>
<td>that key</td>
<td>A record that moved isn't a change; a record whose fields changed is</td>
</tr>
<tr>
<td>Ordered steps, rankings, pages of results</td>
<td>position</td>
<td>The order is the data, so a swap is a real change</td>
</tr>
<tr>
<td>Sets, such as tags or permissions</td>
<td>ignoring order</td>
<td>Only membership matters</td>
</tr>
</tbody></table>
<p>The function above doesn't do the third mode. A starting point is to sort both arrays by a canonical string of each element before comparing them by position. Sorting alone isn't true set semantics, though: if a value appears twice in one array and once in the other, you still need a rule for whether that's a change. Don't apply "ignore order" to a whole document: it hides real changes in any array where order matters.</p>
<h2>Edge cases worth deciding on</h2>
<p><strong>Records without the id, or with a duplicate id.</strong> Matching by key needs every record to have a unique id. The function throws instead of guessing:</p>
<pre><code class="language-text">$.items[1] has no sku
$.items: duplicate sku 1
</code></pre>
<p>A more forgiving version could fall back to position for those records, but say so in the output.</p>
<p><strong>An id that changes.</strong> The id is how records are paired, so if a record's <code>sku</code> itself changes, the function reports one record removed and another added, not an id change. That's a reasonable rule, but it means the id field has to be stable as well as unique.</p>
<p><strong>Missing versus <code>null</code>.</strong> These aren't the same change, and clients often treat them differently:</p>
<pre><code class="language-js">compare({ x: 1, y: 2 }, { x: null });
// [ { path: '$.x', change: 'type', from: 1, to: null },
//   { path: '$.y', change: 'removed', from: 2 } ]
</code></pre>
<p><strong>Ignored names apply at every depth.</strong> <code>ignore: ['updatedAt']</code> matches by field name, so it also skips <code>updatedAt</code> inside each item. That's usually what you want for timestamps, request ids and ETags. If you need to ignore one specific location, match on the full path instead.</p>
<p><strong>Large integers are lost before you compare.</strong> <a href="https://curlyjson.com/blog/json-large-number-precision">JavaScript can silently round large JSON integers</a>: <code>JSON.parse</code> rounds integers above 2^53, so two different 64-bit IDs can compare as equal:</p>
<pre><code class="language-js">compare(JSON.parse('{"user": {"id": 9007199254740993}}'),
        JSON.parse('{"user": {"id": 9007199254740992}}'));
// []
</code></pre>
<p>If your responses carry IDs that large, parse them with a lossless parser, or compare the IDs as strings.</p>
<h2>Takeaways</h2>
<ul>
<li>Compare parsed values, not lines. That alone removes key-order and whitespace noise.</li>
<li>Decide per array whether position matters. For records with ids, match by the id.</li>
<li>Report type changes separately. They're often contract breaks, not data changes.</li>
<li>Ignore fields that change on every call, by name or by path.</li>
<li>Watch out for numbers above 2^53 when parsing with <code>JSON.parse</code>.</li>
</ul>
<h2>Compare JSON responses without writing a diff function</h2>
<p>If you need to compare two API responses quickly, try <a href="https://curlyjson.com/json-diff">CurlyJSON's JSON Diff</a>. Paste the two documents from this article and compare them in your browser. It detects common record identifiers such as <code>id</code>, <code>uuid</code> and <code>sku</code>, labels type changes separately, and supports wildcard patterns for ignoring fields. It also keeps large numbers exact, so it avoids the precision loss that <code>JSON.parse</code> causes. Neither response is uploaded.</p>
<p>The function above is useful when you need custom comparison logic in your own code. CurlyJSON is useful when you just need to see what changed.</p>
<hr />
<p><em>Written with AI assistance. Every code sample was run in Node 23, and the outputs shown are the actual results.</em></p>
]]></content:encoded></item></channel></rss>