<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://forgehub.link/">
  <title>ForgeHub blog</title>
  <subtitle>Practical guides to comparing and understanding JSON.</subtitle>
  <id>https://forgehub.link/blog/</id>
  <link rel="alternate" type="text/html" href="https://forgehub.link/blog/"/>
  <link rel="self" type="application/atom+xml" href="https://forgehub.link/feed.xml"/>
  <updated>2026-10-15T00:00:00Z</updated>
  <author><name>ForgeHub</name><uri>https://forgehub.link/</uri></author>
  <icon>https://forgehub.link/favicon-32.png</icon>
  <entry>
    <title>How to compare two JSON files without uploading them</title>
    <id>https://forgehub.link/blog/compare-json-files-without-uploading/</id>
    <link rel="alternate" type="text/html" href="https://forgehub.link/blog/compare-json-files-without-uploading/"/>
    <published>2026-10-15T00:00:00Z</published>
    <updated>2026-10-15T00:00:00Z</updated>
    <summary>Compare two JSON files privately with a diff tool that runs in your browser, or offline with jq or Python, so secrets and customer data never leave your device.</summary>
    <category term="JSON"/>
    <category term="Privacy"/>
    <category term="Diff"/>
    <category term="How-to"/>
    <content type="html">&lt;p class=&quot;lede&quot;&gt;To compare two JSON files without uploading them, use a diff tool that parses and compares the JSON entirely inside your browser tab, such as &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt;, or compare them offline with &lt;code&gt;jq&lt;/code&gt; or a few lines of Python. Either way the documents never leave your machine, which matters when they contain API keys, customer records or internal configuration.&lt;/p&gt;

&lt;h2 id=&quot;why-not-paste-json-into-online-diff-sites&quot;&gt;Why you shouldn't paste sensitive JSON into any online diff site&lt;/h2&gt;
&lt;p&gt;The JSON people need to compare is rarely harmless. It is usually one of these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Configuration files&lt;/strong&gt; that contain database URLs, API keys, OAuth client secrets or feature flags you have not announced.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API responses&lt;/strong&gt; that include access tokens, session IDs, or internal identifiers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Customer data&lt;/strong&gt; such as names, emails, addresses and order histories: personal data that your privacy policy, contracts or regulations may restrict.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Many hosted &quot;JSON diff online&quot; tools send what you paste to a server, either to compute the diff or to create a shareable link. Once the data is on someone else's server, you cannot see whether it is logged, cached, indexed or kept. For a secret, the safe assumption is that it has been exposed and must be rotated. For personal data, pasting it into an unvetted third-party service may break your organisation's data-handling rules even if nothing bad ever happens to it.&lt;/p&gt;
&lt;p&gt;The fix is simple: only use a comparison method where the JSON stays on your device. There are three practical options, described below.&lt;/p&gt;

&lt;h2 id=&quot;compare-two-json-documents-in-json-mirror&quot;&gt;How to compare two JSON documents in your browser, step by step&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; is a free JSON diff that runs entirely in the browser tab. There is no account and no install, it makes no network requests while you work, and it uses no analytics, cookies or browser storage. Close the tab and the data is gone.&lt;/p&gt;
&lt;h3&gt;1. Paste the two documents&lt;/h3&gt;
&lt;p&gt;Paste the older version into &lt;strong&gt;Original&lt;/strong&gt; and the newer one into &lt;strong&gt;Updated&lt;/strong&gt;. To follow along, press &lt;strong&gt;Load example&lt;/strong&gt;, which fills in these two documents:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;project&quot;:&quot;JSON Mirror&quot;,&quot;version&quot;:&quot;1.0&quot;,&quot;settings&quot;:{&quot;theme&quot;:&quot;light&quot;,&quot;notifications&quot;:true},&quot;members&quot;:[&quot;Alex&quot;,&quot;Sam&quot;],&quot;status&quot;:&quot;draft&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{&quot;project&quot;:&quot;JSON Mirror&quot;,&quot;version&quot;:&quot;1.1&quot;,&quot;settings&quot;:{&quot;theme&quot;:&quot;dark&quot;,&quot;notifications&quot;:true},&quot;members&quot;:[&quot;Alex&quot;,&quot;Sam&quot;,&quot;Jordan&quot;],&quot;published&quot;:true}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a document is minified, the &lt;strong&gt;Format&lt;/strong&gt; button pretty-prints that editor. &lt;strong&gt;Swap&lt;/strong&gt; exchanges the two inputs if you pasted them the wrong way round.&lt;/p&gt;
&lt;h3&gt;2. Press Compare JSON&lt;/h3&gt;
&lt;p&gt;Click &lt;strong&gt;Compare JSON&lt;/strong&gt; or press Ctrl+Enter (Cmd+Enter on a Mac). JSON Mirror parses both documents and compares their structure, not their text, so whitespace and object key order are ignored. (For why that is the right default, see &lt;a href=&quot;/blog/json-diff-key-order-whitespace/&quot;&gt;why key order and whitespace shouldn't show up in a JSON diff&lt;/a&gt;.) The example produces five changes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Kind&lt;/th&gt;&lt;th&gt;Path&lt;/th&gt;&lt;th&gt;Original&lt;/th&gt;&lt;th&gt;Updated&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;changed&lt;/td&gt;&lt;td&gt;&lt;code&gt;$.version&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;1.0&quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;1.1&quot;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;changed&lt;/td&gt;&lt;td&gt;&lt;code&gt;$.settings.theme&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;light&quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;dark&quot;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;added&lt;/td&gt;&lt;td&gt;&lt;code&gt;$.members[2]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;(none)&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;Jordan&quot;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;removed&lt;/td&gt;&lt;td&gt;&lt;code&gt;$.status&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;draft&quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;(none)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;added&lt;/td&gt;&lt;td&gt;&lt;code&gt;$.published&lt;/code&gt;&lt;/td&gt;&lt;td&gt;(none)&lt;/td&gt;&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each entry also carries a one-line plain-English explanation and shows the original and updated values side by side.&lt;/p&gt;
&lt;h3&gt;3. Filter the results&lt;/h3&gt;
&lt;p&gt;Use the &lt;strong&gt;All / Changed / Added / Removed&lt;/strong&gt; filter to focus. In the example, Changed shows the two edited values, Added shows the new array item and the new &lt;code&gt;published&lt;/code&gt; key, and Removed shows only &lt;code&gt;$.status&lt;/code&gt;. The screen lists the first 300 entries per filter.&lt;/p&gt;
&lt;h3&gt;4. Read the paths&lt;/h3&gt;
&lt;p&gt;Every path starts at &lt;code&gt;$&lt;/code&gt;, the root of the document. Simple keys use dot notation (&lt;code&gt;$.settings.theme&lt;/code&gt;), array positions use brackets (&lt;code&gt;$.members[2]&lt;/code&gt; is the third item), and unusual keys use quoted brackets, such as &lt;code&gt;$[&quot;first name&quot;]&lt;/code&gt;. The guide to &lt;a href=&quot;/blog/reading-json-paths/&quot;&gt;reading JSON paths in a diff&lt;/a&gt; covers the details.&lt;/p&gt;
&lt;h3&gt;5. Export the report&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Export report&lt;/strong&gt; downloads a JSON report with every entry, not just the first 300, generated in the browser and saved to your machine. If you edit either input after comparing, the results are marked stale and export is disabled until you compare again, so a report always matches the inputs it came from. For what the file contains, see &lt;a href=&quot;/blog/export-json-differences-beyond-300-results/&quot;&gt;how to export JSON differences beyond the 300-result view&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Know the limits&lt;/h3&gt;
&lt;p&gt;Arrays are compared by index, so inserting an item near the start shows up as several &quot;changed&quot; entries plus one &quot;added&quot; entry; moves are not detected (see &lt;a href=&quot;/blog/json-array-diff-index-vs-identity/&quot;&gt;comparing JSON arrays by position or by identity&lt;/a&gt;). Numbers are parsed with the standard JavaScript &lt;code&gt;JSON.parse&lt;/code&gt;, so integers larger than 9,007,199,254,740,991 can lose precision. Each input is limited to 1,000,000 characters, and nesting deeper than 120 levels is rejected.&lt;/p&gt;

&lt;h2 id=&quot;offline-json-compare-with-jq&quot;&gt;How to compare JSON files offline with jq&lt;/h2&gt;
&lt;p&gt;If you already have jq installed, this one-liner works in bash and zsh:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;diff &amp;lt;(jq -S . a.json) &amp;lt;(jq -S . b.json)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;jq .&lt;/code&gt; pretty-prints each file with consistent indentation, and &lt;code&gt;-S&lt;/code&gt; sorts the keys of every object. That normalises both files, so differences in key order and whitespace disappear. &lt;code&gt;diff&lt;/code&gt; then compares the two normalised outputs line by line.&lt;/p&gt;
&lt;p&gt;Keep in mind that this is still a text diff of the normalised output. It tells you which lines differ, not which JSON paths, and a change deep in a large file is shown with line numbers rather than &lt;code&gt;$.settings.theme&lt;/code&gt;. Array order is preserved, so reordered arrays still show up as changes. For a quick check in a terminal or a CI job, it is hard to beat.&lt;/p&gt;

&lt;h2 id=&quot;compare-two-json-files-in-python&quot;&gt;How to check whether two JSON files are equal in Python&lt;/h2&gt;
&lt;p&gt;When you only need a yes or no, Python's standard library is enough:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json

with open(&quot;a.json&quot;) as f:
    a = json.load(f)
with open(&quot;b.json&quot;) as f:
    b = json.load(f)

print(&quot;equal&quot; if a == b else &quot;different&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Dictionary comparison ignores key order and list comparison respects position, so &lt;code&gt;==&lt;/code&gt; gives the same answer as a structural diff. Python also parses JSON integers exactly, so large IDs compare correctly. What it does not tell you is &lt;em&gt;where&lt;/em&gt; the documents differ. For that you would need to write a recursive walk, or use one of the tools above.&lt;/p&gt;

&lt;h2 id=&quot;which-json-compare-method-to-use&quot;&gt;Which method should you use?&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Best for&lt;/th&gt;&lt;th&gt;Output&lt;/th&gt;&lt;th&gt;Trade-off&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;JSON Mirror (browser)&lt;/td&gt;&lt;td&gt;Reviewing changes, sharing a report, no tools installed&lt;/td&gt;&lt;td&gt;Kind, path, explanation and values per change&lt;/td&gt;&lt;td&gt;Index-based arrays; large integers may lose precision&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;jq -S&lt;/code&gt; + &lt;code&gt;diff&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Terminals, scripts, CI&lt;/td&gt;&lt;td&gt;Line-based text diff&lt;/td&gt;&lt;td&gt;No JSON paths; needs jq and a shell with process substitution&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Python &lt;code&gt;==&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Automated equality checks, exact big integers&lt;/td&gt;&lt;td&gt;Equal or different&lt;/td&gt;&lt;td&gt;Does not say where the difference is&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;how-to-tell-if-a-json-diff-tool-is-local&quot;&gt;Checklist: how to tell if a JSON diff tool is really local&lt;/h2&gt;
&lt;p&gt;&quot;Private&quot; and &quot;secure&quot; on a landing page are claims, not proof. You can check a browser-based tool yourself in a couple of minutes, using dummy data first:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Watch the Network tab.&lt;/strong&gt; Open DevTools (F12), go to the Network tab, clear it, then paste and compare. A local tool makes no new requests when you compare.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go offline.&lt;/strong&gt; Load the page, disconnect from the network, and run a comparison. If it still works, the diff is computed on your device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Look for share links.&lt;/strong&gt; A &quot;share&quot; or &quot;save&quot; button that produces a URL to your diff usually means your input is stored on a server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check for accounts and trackers.&lt;/strong&gt; A tool that requires sign-in, or loads analytics and advertising scripts, has more places for data to go.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read the privacy statement.&lt;/strong&gt; It should say plainly that input is not uploaded, and that should match what you saw in steps 1 and 2.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;
&lt;p&gt;The safest way to compare two JSON files is one where the data never leaves your machine. For a readable, path-by-path report, open &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt;, press Load example, and compare. For scripts and CI, reach for &lt;code&gt;jq -S&lt;/code&gt; with &lt;code&gt;diff&lt;/code&gt;, or a Python equality check when you only need to know whether anything changed.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Why key order and whitespace shouldn't show up in a JSON diff</title>
    <id>https://forgehub.link/blog/json-diff-key-order-whitespace/</id>
    <link rel="alternate" type="text/html" href="https://forgehub.link/blog/json-diff-key-order-whitespace/"/>
    <published>2026-10-15T00:00:00Z</published>
    <updated>2026-10-15T00:00:00Z</updated>
    <summary>A JSON diff should ignore key order and whitespace. Learn why RFC 8259 treats them as insignificant and what a structural JSON comparison still flags.</summary>
    <category term="JSON"/>
    <category term="Diff"/>
    <category term="Git"/>
    <category term="Data formats"/>
    <content type="html">&lt;p class=&quot;lede&quot;&gt;Key order and whitespace shouldn't appear in a JSON diff because they aren't part of the data. RFC 8259 defines a JSON object as an unordered collection of name/value pairs and allows insignificant whitespace around structural characters. A text diff still reports them as changes. A structural (semantic) JSON diff parses both documents first, so it reports only real differences in values, types and structure.&lt;/p&gt;

&lt;h2 id=&quot;what-the-json-spec-says&quot;&gt;What the JSON spec says about key order and whitespace&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8259&quot;&gt;RFC 8259&lt;/a&gt;, the current JSON standard, settles both points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Key order:&lt;/strong&gt; &quot;An object is an unordered collection of zero or more name/value pairs.&quot; The objects &lt;code&gt;{&quot;a&quot;:1,&quot;b&quot;:2}&lt;/code&gt; and &lt;code&gt;{&quot;b&quot;:2,&quot;a&quot;:1}&lt;/code&gt; hold the same data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Whitespace:&lt;/strong&gt; &quot;Insignificant whitespace is allowed before or after any of the six structural characters&quot; (&lt;code&gt;{ } [ ] : ,&lt;/code&gt;). That whitespace can be any mix of spaces, tabs, line feeds and carriage returns. Indentation, line breaks, CRLF vs LF and a trailing newline at the end of the file don't change what a JSON document means.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A &lt;strong&gt;structural JSON comparison&lt;/strong&gt; parses each document into values (objects, arrays, strings, numbers, booleans, null) and compares those values. A &lt;strong&gt;text diff&lt;/strong&gt; compares the characters or lines. Most of the confusion in JSON reviews comes from mixing up these two kinds of diff.&lt;/p&gt;

&lt;h2 id=&quot;text-diff-with-zero-data-changes&quot;&gt;Example: a 14-line git diff with zero data changes&lt;/h2&gt;
&lt;p&gt;Here's a config file stored minified on one line, with no trailing newline:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;name&quot;:&quot;build-agent&quot;,&quot;id&quot;:4182,&quot;enabled&quot;:true,&quot;tags&quot;:[&quot;ci&quot;,&quot;linux&quot;],&quot;limits&quot;:{&quot;memoryMb&quot;:8192,&quot;cpu&quot;:4}}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Someone opens it in a tool that pretty-prints with two-space indentation, sorts keys alphabetically and adds a final newline. Then they commit it. Here's what &lt;code&gt;git diff&lt;/code&gt; shows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@@ -1 +1,13 @@
-{&quot;name&quot;:&quot;build-agent&quot;,&quot;id&quot;:4182,&quot;enabled&quot;:true,&quot;tags&quot;:[&quot;ci&quot;,&quot;linux&quot;],&quot;limits&quot;:{&quot;memoryMb&quot;:8192,&quot;cpu&quot;:4}}
\ No newline at end of file
+{
+  &quot;enabled&quot;: true,
+  &quot;id&quot;: 4182,
+  &quot;limits&quot;: {
+    &quot;cpu&quot;: 4,
+    &quot;memoryMb&quot;: 8192
+  },
+  &quot;name&quot;: &quot;build-agent&quot;,
+  &quot;tags&quot;: [
+    &quot;ci&quot;,
+    &quot;linux&quot;
+  ]
+}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's 13 insertions and 1 deletion, and not one value changed. A structural diff of the same two files finds zero changes. Every key and value, and every array position, is identical.&lt;/p&gt;

&lt;h2 id=&quot;real-change-hidden-in-noise&quot;&gt;Example: one real change hidden in the noise&lt;/h2&gt;
&lt;p&gt;Now suppose the same commit also raised the memory limit from &lt;code&gt;8192&lt;/code&gt; to &lt;code&gt;16384&lt;/code&gt;. &lt;code&gt;git diff --stat&lt;/code&gt; reports exactly the same thing as before: &lt;code&gt;13 insertions(+), 1 deletion(-)&lt;/code&gt;. The real change is on one of those 13 green lines, and nothing marks it as different from the formatting noise. A reviewer has to compare a 100-character line against 13 reformatted lines in their head.&lt;/p&gt;
&lt;p&gt;A structural diff reports one entry:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;changed  $.limits.memoryMb   8192 -&amp;gt; 16384&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the core argument for a &lt;strong&gt;semantic JSON diff&lt;/strong&gt;. When formatting and data change in the same commit, a line diff can't separate them. &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; compares parsed structure, so this example produces exactly one &quot;changed&quot; entry with its path. (If the path notation is new to you, see &lt;a href=&quot;/blog/reading-json-paths/&quot;&gt;Reading JSON paths in a diff&lt;/a&gt;.)&lt;/p&gt;

&lt;h2 id=&quot;where-formatting-noise-comes-from&quot;&gt;Where JSON formatting noise comes from&lt;/h2&gt;
&lt;h3&gt;Serializers that reorder keys&lt;/h3&gt;
&lt;p&gt;Different tools write the same object in different key orders:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Python's &lt;code&gt;json.dumps(obj, sort_keys=True)&lt;/code&gt; sorts keys. Go's &lt;code&gt;encoding/json&lt;/code&gt; sorts map keys when it marshals a map.&lt;/li&gt;
&lt;li&gt;PostgreSQL's &lt;code&gt;jsonb&lt;/code&gt; type doesn't preserve key order or whitespace, so data that goes through it comes back in a different order.&lt;/li&gt;
&lt;li&gt;JavaScript objects list integer-like keys first, in ascending order. &lt;code&gt;JSON.stringify(JSON.parse('{&quot;b&quot;:1,&quot;10&quot;:2,&quot;2&quot;:3}'))&lt;/code&gt; returns &lt;code&gt;{&quot;2&quot;:3,&quot;10&quot;:2,&quot;b&quot;:1}&lt;/code&gt;, even though nobody asked for a reorder.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Pretty-printed vs minified&lt;/h3&gt;
&lt;p&gt;APIs often return minified JSON, while files in a repository are usually indented. Indent width (2 spaces, 4 spaces, tabs) also differs between editors and formatters. Converting between any of these rewrites nearly every line.&lt;/p&gt;
&lt;h3&gt;Trailing newline and line endings&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; doesn't add a final newline, but many editors and command-line tools do. Git flags the difference with &quot;No newline at end of file&quot;, and CRLF vs LF conversion on Windows can touch every line. To a JSON parser, both are insignificant whitespace.&lt;/p&gt;

&lt;h2 id=&quot;what-structural-diff-still-flags&quot;&gt;What a structural JSON comparison still treats as meaningful&lt;/h2&gt;
&lt;p&gt;Ignoring formatting doesn't mean ignoring everything. These differences are real, and a good structural diff reports them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Array order.&lt;/strong&gt; Arrays are ordered in JSON, so &lt;code&gt;[&quot;ci&quot;,&quot;linux&quot;]&lt;/code&gt; and &lt;code&gt;[&quot;linux&quot;,&quot;ci&quot;]&lt;/code&gt; are different. JSON Mirror compares arrays by index, so this shows up as two changed entries, &lt;code&gt;$.tags[0]&lt;/code&gt; and &lt;code&gt;$.tags[1]&lt;/code&gt;. It doesn't detect moves. If your arrays are really sets or lists of records with IDs, read &lt;a href=&quot;/blog/json-array-diff-index-vs-identity/&quot;&gt;Comparing JSON arrays: by position or by identity?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Types.&lt;/strong&gt; &lt;code&gt;&quot;1&quot;&lt;/code&gt; (a string) and &lt;code&gt;1&lt;/code&gt; (a number) are different values. Likewise &lt;code&gt;&quot;true&quot;&lt;/code&gt; and &lt;code&gt;true&lt;/code&gt;. JSON Mirror reports a type change as one change at that path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing vs null.&lt;/strong&gt; &lt;code&gt;{&quot;a&quot;:null}&lt;/code&gt; and &lt;code&gt;{}&lt;/code&gt; aren't the same document, and JSON Mirror treats them as different.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Number representation subtleties&lt;/h3&gt;
&lt;p&gt;A JavaScript-based diff parses every number as an IEEE-754 double. That has two consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1.0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt; parse to the same number, as do &lt;code&gt;1e2&lt;/code&gt; and &lt;code&gt;100&lt;/code&gt;, so they compare equal. Usually that's what you want.&lt;/li&gt;
&lt;li&gt;Integers beyond 2^53 - 1 (9,007,199,254,740,991) lose precision. &lt;code&gt;JSON.parse(&quot;9007199254740993&quot;)&lt;/code&gt; returns &lt;code&gt;9007199254740992&lt;/code&gt;, so a change between those two values is invisible. For large IDs or exact decimal amounts, compare with a parser that keeps integers exact (Python's &lt;code&gt;json&lt;/code&gt; module does) or store such values as strings.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Duplicate keys&lt;/h3&gt;
&lt;p&gt;RFC 8259 says names within an object SHOULD be unique, but it doesn't forbid duplicates. &lt;code&gt;JSON.parse&lt;/code&gt; keeps the last value, so &lt;code&gt;{&quot;a&quot;:1,&quot;a&quot;:2}&lt;/code&gt; parses to &lt;code&gt;{&quot;a&quot;:2}&lt;/code&gt; and compares equal to it. A text diff would show the duplicate. A structural diff built on &lt;code&gt;JSON.parse&lt;/code&gt; won't, so if duplicates might matter in your data, check for them separately.&lt;/p&gt;

&lt;h2 id=&quot;text-diff-vs-structural-diff&quot;&gt;Text diff vs structural diff&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Difference between the two files&lt;/th&gt;&lt;th&gt;Text diff (e.g. git diff)&lt;/th&gt;&lt;th&gt;Structural diff&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Indentation, spaces, line breaks&lt;/td&gt;&lt;td&gt;Changed lines&lt;/td&gt;&lt;td&gt;Ignored&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Minified vs pretty-printed&lt;/td&gt;&lt;td&gt;Almost every line changed&lt;/td&gt;&lt;td&gt;Ignored&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Object key order&lt;/td&gt;&lt;td&gt;Changed lines&lt;/td&gt;&lt;td&gt;Ignored&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Trailing newline, CRLF vs LF&lt;/td&gt;&lt;td&gt;Flagged&lt;/td&gt;&lt;td&gt;Ignored&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A changed value&lt;/td&gt;&lt;td&gt;A changed line, possibly buried in noise&lt;/td&gt;&lt;td&gt;One entry with its path&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Array items reordered&lt;/td&gt;&lt;td&gt;Changed lines&lt;/td&gt;&lt;td&gt;Changed entries (when compared by index)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;&quot;1&quot;&lt;/code&gt; vs &lt;code&gt;1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Changed line&lt;/td&gt;&lt;td&gt;Type change&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;1.0&lt;/code&gt; vs &lt;code&gt;1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Changed line&lt;/td&gt;&lt;td&gt;Equal (same parsed number)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Integers beyond 2^53 - 1&lt;/td&gt;&lt;td&gt;Changed line&lt;/td&gt;&lt;td&gt;May be missed by double-based parsers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Duplicate keys&lt;/td&gt;&lt;td&gt;Visible&lt;/td&gt;&lt;td&gt;Collapsed to the last value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Invalid JSON&lt;/td&gt;&lt;td&gt;Still diffs the text&lt;/td&gt;&lt;td&gt;Can't compare until it parses&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;make-git-diff-ignore-json-key-order&quot;&gt;How to make git diff ignore JSON key order and whitespace&lt;/h2&gt;
&lt;p&gt;If you want to keep using git for review, normalize both versions before comparing. &lt;code&gt;jq -S .&lt;/code&gt; re-indents the document and sorts object keys recursively:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;diff &amp;lt;(jq -S . before.json) &amp;lt;(jq -S . after.json)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make &lt;code&gt;git diff&lt;/code&gt; do this automatically, add a textconv filter. In &lt;code&gt;.gitattributes&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*.json diff=json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then configure the filter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git config diff.json.textconv &quot;jq -S .&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Git now shows diffs of the normalized output, while the files in the repository stay unchanged. A few caveats apply. A file that isn't valid JSON makes jq fail, so the diff fails too. Array order is still compared line by line. And jq doesn't necessarily normalize how a number is written, so &lt;code&gt;1.0&lt;/code&gt; vs &lt;code&gt;1&lt;/code&gt; may still appear. Older jq versions (before 1.7) also convert numbers to doubles, which brings back the large-integer problem.&lt;/p&gt;
&lt;p&gt;A short script works too. In Python, &lt;code&gt;json.load(f1) == json.load(f2)&lt;/code&gt; ignores key order and whitespace and keeps large integers exact. One caveat: Python treats &lt;code&gt;True == 1&lt;/code&gt; and &lt;code&gt;1 == 1.0&lt;/code&gt; as equal, so &lt;code&gt;[true]&lt;/code&gt; and &lt;code&gt;[1]&lt;/code&gt; would compare equal.&lt;/p&gt;

&lt;h2 id=&quot;try-it&quot;&gt;Try a structural JSON diff&lt;/h2&gt;
&lt;p&gt;A JSON diff should answer one question: what changed in the data? Key order, indentation and trailing newlines don't affect the data, so they shouldn't take up space in the result. Array order, types, null vs missing, and changed values do, so they should.&lt;/p&gt;
&lt;p&gt;To see this on your own files, paste both versions into &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt;. It ignores whitespace and key order, lists each change with its path, and runs entirely in your browser, so nothing is uploaded. For large integers or arrays where position isn't meaningful, use the alternatives above.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>null vs. missing: the JSON difference that breaks APIs</title>
    <id>https://forgehub.link/blog/json-null-vs-missing-keys/</id>
    <link rel="alternate" type="text/html" href="https://forgehub.link/blog/json-null-vs-missing-keys/"/>
    <published>2026-10-15T00:00:00Z</published>
    <updated>2026-10-15T00:00:00Z</updated>
    <summary>JSON null vs missing keys: why {&quot;nickname&quot;: null} and {} mean different things in PATCH and JSON Merge Patch, and how to tell them apart in a diff.</summary>
    <category term="JSON"/>
    <category term="APIs"/>
    <category term="JavaScript"/>
    <category term="JSON diff"/>
    <content type="html">&lt;p class=&quot;lede&quot;&gt;In JSON, &lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt; and &lt;code&gt;{}&lt;/code&gt; are not the same document. The first says &quot;this field exists and has no value&quot;; the second says nothing about the field at all. APIs routinely treat them differently, most visibly in PATCH requests and JSON Merge Patch, where &lt;code&gt;null&lt;/code&gt; means &quot;delete this field&quot; and a missing key means &quot;leave it alone&quot;, so any tool that compares JSON must keep the two cases apart.&lt;/p&gt;

&lt;p&gt;This post explains where the difference matters, the JavaScript and serializer habits that blur it, and how &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; reports each case when you diff two documents.&lt;/p&gt;

&lt;h2 id=&quot;what-null-and-missing-mean&quot;&gt;What null and a missing key mean in JSON&lt;/h2&gt;

&lt;p&gt;JSON, as defined in &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8259&quot;&gt;RFC 8259&lt;/a&gt;, has six kinds of value: object, array, string, number, the literals &lt;code&gt;true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt;, and the literal &lt;code&gt;null&lt;/code&gt;. There is no &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Explicit null:&lt;/strong&gt; the key is present in the object and its value is &lt;code&gt;null&lt;/code&gt;. &lt;code&gt;Object.keys&lt;/code&gt; lists it; the document is one member larger.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing key:&lt;/strong&gt; the key is absent. The document makes no statement about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether those two states mean different things is up to the API. But because they &lt;em&gt;can&lt;/em&gt; mean different things, a parser, a serializer, or a diff that silently converts one into the other is changing the message.&lt;/p&gt;

&lt;h2 id=&quot;json-merge-patch-null&quot;&gt;JSON Merge Patch: null means remove&lt;/h2&gt;

&lt;p&gt;The clearest example is JSON Merge Patch, specified in &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc7396&quot;&gt;RFC 7396&lt;/a&gt; and sent with the media type &lt;code&gt;application/merge-patch+json&lt;/code&gt;. A merge patch looks like a partial copy of the resource, and the rules are short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A member with a non-null value in the patch replaces (or adds) that member in the target. Nested objects are merged recursively.&lt;/li&gt;
&lt;li&gt;A member set to &lt;code&gt;null&lt;/code&gt; in the patch &lt;strong&gt;removes&lt;/strong&gt; that member from the target.&lt;/li&gt;
&lt;li&gt;A member that is &lt;strong&gt;absent&lt;/strong&gt; from the patch is left unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Given this stored user:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{&quot;name&quot;: &quot;Alex&quot;, &quot;nickname&quot;: &quot;Al&quot;, &quot;email&quot;: &quot;alex@example.com&quot;}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;these three patches do three different things:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{}                     -&amp;gt; nothing changes
{&quot;nickname&quot;: &quot;Lex&quot;}    -&amp;gt; nickname becomes &quot;Lex&quot;
{&quot;nickname&quot;: null}     -&amp;gt; nickname is deleted:
                          {&quot;name&quot;: &quot;Alex&quot;, &quot;email&quot;: &quot;alex@example.com&quot;}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One consequence the RFC itself points out: merge patch is a poor fit for documents that rely on explicit &lt;code&gt;null&lt;/code&gt; values, because you cannot use it to set a member &lt;em&gt;to&lt;/em&gt; null. Arrays are also replaced wholesale rather than edited. If you need either of those, JSON Patch (&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6902&quot;&gt;RFC 6902&lt;/a&gt;) uses explicit &lt;code&gt;add&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt; and &lt;code&gt;replace&lt;/code&gt; operations instead.&lt;/p&gt;

&lt;h2 id=&quot;patch-endpoints-partial-updates&quot;&gt;PATCH endpoints and partial updates&lt;/h2&gt;

&lt;p&gt;Plenty of PATCH endpoints follow the same convention without formally implementing RFC 7396: send only the fields you want to change, send &lt;code&gt;null&lt;/code&gt; to clear a field. That convention only works if every layer between the client and the database preserves the difference between &quot;absent&quot; and &quot;null&quot;. Common ways it breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server deserializes the body into a typed object where a missing field and a null field both end up as &lt;code&gt;null&lt;/code&gt;. The handler then clears every field the client did not send.&lt;/li&gt;
&lt;li&gt;The client builds the body from a form model and serializes every property, so untouched fields go out as &lt;code&gt;null&lt;/code&gt; and wipe stored data.&lt;/li&gt;
&lt;li&gt;The client's serializer is configured to omit nulls, so a deliberate &quot;clear this field&quot; request arrives as an empty object and does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix on the server side is usually to inspect the raw JSON tree for key presence (for example Jackson's &lt;code&gt;JsonNode.has(&quot;nickname&quot;)&lt;/code&gt;, or &lt;code&gt;'nickname' in body&lt;/code&gt; in Python) before mapping it into a model.&lt;/p&gt;

&lt;h2 id=&quot;javascript-null-vs-undefined&quot;&gt;JSON null vs undefined in JavaScript&lt;/h2&gt;

&lt;p&gt;JavaScript has two &quot;empty&quot; values, and the language's own operators make it easy to merge them by accident:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const missing = JSON.parse('{}');
const isNull  = JSON.parse('{&quot;nickname&quot;: null}');

missing.nickname === undefined;     // true
isNull.nickname === undefined;      // false (it is null)

missing.nickname == null;           // true
isNull.nickname == null;            // true  (null == undefined is true)

'nickname' in missing;              // false
Object.hasOwn(isNull, 'nickname');  // true&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Things to remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;obj.key === undefined&lt;/code&gt; is true both when the key is missing and when it exists with the value &lt;code&gt;undefined&lt;/code&gt;. It cannot tell you whether the key is there.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;null == undefined&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, so a loose &lt;code&gt;== null&lt;/code&gt; check treats null and missing identically. &lt;code&gt;null === undefined&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; drops object properties whose value is &lt;code&gt;undefined&lt;/code&gt;: &lt;code&gt;JSON.stringify({nickname: undefined})&lt;/code&gt; produces &lt;code&gt;{}&lt;/code&gt;, while &lt;code&gt;JSON.stringify({nickname: null})&lt;/code&gt; produces &lt;code&gt;{&quot;nickname&quot;:null}&lt;/code&gt;. (Inside arrays, &lt;code&gt;undefined&lt;/code&gt; becomes &lt;code&gt;null&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;To test for presence, use &lt;code&gt;Object.hasOwn(obj, 'key')&lt;/code&gt; or &lt;code&gt;'key' in obj&lt;/code&gt;. The &lt;code&gt;in&lt;/code&gt; operator also looks up the prototype chain, which rarely matters for parsed JSON but is worth knowing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if you want a JavaScript client to send &quot;clear this field&quot;, set it to &lt;code&gt;null&lt;/code&gt;. If you want &quot;leave it alone&quot;, delete the property or never set it; &lt;code&gt;undefined&lt;/code&gt; also works, but only because &lt;code&gt;JSON.stringify&lt;/code&gt; happens to drop it.&lt;/p&gt;

&lt;h2 id=&quot;omit-null-fields-json&quot;&gt;Serializers that omit null fields&lt;/h2&gt;

&lt;p&gt;Many JSON serializers have an option to omit null fields from the output, often to make payloads smaller. In Java's Jackson, for example, annotating a class or property with &lt;code&gt;@JsonInclude(JsonInclude.Include.NON_NULL)&lt;/code&gt; leaves null-valued properties out entirely.&lt;/p&gt;

&lt;p&gt;That setting is harmless for read-only responses where clients treat null and missing the same way. It is risky for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request bodies sent to a PATCH or merge-patch endpoint, where null carries meaning;&lt;/li&gt;
&lt;li&gt;responses whose consumers check for key presence, such as schemas with required-but-nullable fields;&lt;/li&gt;
&lt;li&gt;snapshot or contract tests, where flipping the setting changes every fixture at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you change a serializer setting like this, diff a before and after payload to see exactly which keys appeared or disappeared.&lt;/p&gt;

&lt;h2 id=&quot;how-json-mirror-reports-null&quot;&gt;How a JSON diff should report null vs missing&lt;/h2&gt;

&lt;p&gt;A diff that treats null and missing as equal will hide exactly the changes described above. &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; checks key presence first and compares values second, so each case gets its own entry. Here is what it reports for the field &lt;code&gt;$.nickname&lt;/code&gt;:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Original&lt;/th&gt;&lt;th&gt;Updated&lt;/th&gt;&lt;th&gt;Kind&lt;/th&gt;&lt;th&gt;Explanation shown&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;{}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;added (value &lt;code&gt;null&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;This value exists only in the updated document.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;removed (value &lt;code&gt;null&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;This value was removed from the original document.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;nickname&quot;: &quot;Sam&quot;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;changed&lt;/td&gt;&lt;td&gt;The value type changed from null to string.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;{&quot;nickname&quot;: &quot;Sam&quot;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;changed&lt;/td&gt;&lt;td&gt;The value type changed from string to null.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two points worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An added or removed &lt;code&gt;null&lt;/code&gt; is still a real change. If an API response starts including &lt;code&gt;&quot;nickname&quot;: null&lt;/code&gt; where it used to omit the key, that shows up as an &lt;strong&gt;added&lt;/strong&gt; entry, not as &quot;no difference&quot;.&lt;/li&gt;
&lt;li&gt;Going from &lt;code&gt;null&lt;/code&gt; to a value is a &lt;strong&gt;changed&lt;/strong&gt; entry with a type change, because &lt;code&gt;null&lt;/code&gt; is its own JSON type. Comparing &lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt; with &lt;code&gt;{&quot;nickname&quot;: null}&lt;/code&gt; reports nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Key order and whitespace are ignored, so only presence and value differences appear; see &lt;a href=&quot;/blog/json-diff-key-order-whitespace/&quot;&gt;why key order and whitespace shouldn't show up in a JSON diff&lt;/a&gt;. Paths such as &lt;code&gt;$.nickname&lt;/code&gt; are explained in &lt;a href=&quot;/blog/reading-json-paths/&quot;&gt;reading JSON paths in a diff&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;check-null-vs-missing-command-line&quot;&gt;Checking null vs missing without a diff tool&lt;/h2&gt;

&lt;p&gt;If you only need to check one field, a one-liner is enough. In jq, &lt;code&gt;.nickname&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; for both cases, so use &lt;code&gt;has&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;jq 'has(&quot;nickname&quot;)' user.json&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In Python, &lt;code&gt;d.get(&quot;nickname&quot;)&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; for both, while &lt;code&gt;&quot;nickname&quot; in d&lt;/code&gt; tells them apart:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import json
d = json.loads('{&quot;nickname&quot;: null}')
print(&quot;nickname&quot; in d, d.get(&quot;nickname&quot;))   # True None&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For whole documents, a structural diff is easier than writing these checks by hand.&lt;/p&gt;

&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;

&lt;p&gt;Paste a request body before and after a serializer change, or an API response from two releases, into &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; and press Compare JSON. Keys that went from missing to &lt;code&gt;null&lt;/code&gt;, or from &lt;code&gt;null&lt;/code&gt; to missing, appear as separate added and removed entries, each with its path and both values side by side. Everything runs in your browser tab and nothing is uploaded.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Comparing JSON arrays: by position or by identity?</title>
    <id>https://forgehub.link/blog/json-array-diff-index-vs-identity/</id>
    <link rel="alternate" type="text/html" href="https://forgehub.link/blog/json-array-diff-index-vs-identity/"/>
    <published>2026-10-15T00:00:00Z</published>
    <updated>2026-10-15T00:00:00Z</updated>
    <summary>A practical JSON array diff guide: comparing by index, by an id key, by sequence alignment, or ignoring order, with worked examples and jq sorting tips.</summary>
    <category term="JSON"/>
    <category term="Diff"/>
    <category term="Arrays"/>
    <category term="jq"/>
    <category term="Testing"/>
    <content type="html">&lt;p class=&quot;lede&quot;&gt;There are four common ways to diff JSON arrays: by index (position 0 against position 0), by an identity key (match objects by a field such as &lt;code&gt;id&lt;/code&gt;), by sequence alignment (the longest-common-subsequence approach line diffs use), and as an unordered set. Comparing by index is the simplest and most predictable, but one item inserted at the front makes every later position look changed. If order does not matter in your data, sort both arrays by a stable key before comparing, or turn them into objects keyed by &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;why-arrays-are-hard-to-diff&quot;&gt;Why arrays are the hard part of a JSON diff&lt;/h2&gt;
&lt;p&gt;Objects are easy to diff because members have names. &lt;code&gt;$.settings.theme&lt;/code&gt; in one document clearly corresponds to &lt;code&gt;$.settings.theme&lt;/code&gt; in the other, and key order is irrelevant (see &lt;a href=&quot;/blog/json-diff-key-order-whitespace/&quot;&gt;why key order and whitespace shouldn't show up in a JSON diff&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Array elements have no names, only positions. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8259&quot;&gt;RFC 8259&lt;/a&gt; defines an array as an ordered sequence of values, so order is part of the data. But many real arrays, such as a list of users, tags or permissions, are really collections where order is incidental. A diff tool has to decide which element in the original corresponds to which element in the updated document, and that decision is the whole difference between the strategies below.&lt;/p&gt;

&lt;h2 id=&quot;example-insert-at-front&quot;&gt;The example: one item inserted at the front&lt;/h2&gt;
&lt;p&gt;Every strategy below uses the same pair of documents. The only real change is that a new user was added at the start of the array.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// before.json
{&quot;users&quot;: [
  {&quot;id&quot;: 1, &quot;name&quot;: &quot;Ada&quot;},
  {&quot;id&quot;: 2, &quot;name&quot;: &quot;Grace&quot;},
  {&quot;id&quot;: 3, &quot;name&quot;: &quot;Linus&quot;}
]}

// after.json
{&quot;users&quot;: [
  {&quot;id&quot;: 4, &quot;name&quot;: &quot;Margaret&quot;},
  {&quot;id&quot;: 1, &quot;name&quot;: &quot;Ada&quot;},
  {&quot;id&quot;: 2, &quot;name&quot;: &quot;Grace&quot;},
  {&quot;id&quot;: 3, &quot;name&quot;: &quot;Linus&quot;}
]}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A human reads this as &quot;one user added&quot;. Whether a tool agrees depends on how it matches elements.&lt;/p&gt;

&lt;h2 id=&quot;compare-json-arrays-by-index&quot;&gt;Comparing JSON arrays by index (position)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; element &lt;code&gt;[0]&lt;/code&gt; is compared with element &lt;code&gt;[0]&lt;/code&gt;, &lt;code&gt;[1]&lt;/code&gt; with &lt;code&gt;[1]&lt;/code&gt;, and so on. If the updated array is longer, the extra positions are reported as added; if it is shorter, the missing positions are reported as removed. Objects inside the array are compared field by field.&lt;/p&gt;
&lt;p&gt;This is the strategy &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; uses. For the example above it reports seven entries:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;changed  $.users[0].id     1 -&amp;gt; 4
changed  $.users[0].name   &quot;Ada&quot; -&amp;gt; &quot;Margaret&quot;
changed  $.users[1].id     2 -&amp;gt; 1
changed  $.users[1].name   &quot;Grace&quot; -&amp;gt; &quot;Ada&quot;
changed  $.users[2].id     3 -&amp;gt; 2
changed  $.users[2].name   &quot;Linus&quot; -&amp;gt; &quot;Grace&quot;
added    $.users[3]        {&quot;id&quot;: 3, &quot;name&quot;: &quot;Linus&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every later position reports a change, and the last original element reappears as an &quot;added&quot; entry at the end. Nothing here is wrong: position 0 really did hold Ada and now holds Margaret. It is just not the summary a person wants.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it is still a reasonable default:&lt;/strong&gt; it is predictable. Every path in the output points at a real location in both documents, so you can look up &lt;code&gt;$.users[2]&lt;/code&gt; in either input and see exactly the values shown (more on that in &lt;a href=&quot;/blog/reading-json-paths/&quot;&gt;reading JSON paths in a diff&lt;/a&gt;). It needs no configuration, no schema knowledge and no heuristics, the same input always produces the same output, and it runs in linear time. Appends and in-place edits, which are very common, produce clean results.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; inserts and deletes near the start cause a cascade of &quot;changed&quot; noise, and it does not detect moves. Swapping two items reports changes at both positions.&lt;/p&gt;

&lt;h2 id=&quot;diff-arrays-of-objects-by-id&quot;&gt;Diffing arrays of objects by id (identity key)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; you name a field that uniquely identifies each element, such as &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;sku&lt;/code&gt; or &lt;code&gt;email&lt;/code&gt;. The tool matches elements with the same key value regardless of position, then compares matched pairs field by field. Keys present only in the updated array are added; keys present only in the original are removed.&lt;/p&gt;
&lt;p&gt;For the example, the result is a single entry, shown here in illustrative notation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;added    users[id=4]   {&quot;id&quot;: 4, &quot;name&quot;: &quot;Margaret&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; the output matches human intent, moves are ignored, and an edited user is reported as a change to that user rather than to a position. With a hash map it is roughly linear in cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; it needs schema-specific knowledge, so a generic tool has to be told which key to use, and different arrays in one document may need different keys. Keys must be present and unique; duplicate or missing ids silently merge or drop elements. It does not apply to arrays of plain values like &lt;code&gt;[&quot;a&quot;, &quot;b&quot;]&lt;/code&gt;, and it hides reordering, which matters if order is meaningful (a priority list, for instance).&lt;/p&gt;

&lt;h2 id=&quot;sequence-alignment-lcs&quot;&gt;Sequence alignment: how line diffs handle arrays&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; the tool finds the longest common subsequence (LCS) of elements that are exactly equal in both arrays, then reports everything outside it as inserted or deleted. Myers' algorithm, used by &lt;code&gt;git diff&lt;/code&gt; and GNU &lt;code&gt;diff&lt;/code&gt;, is an efficient way to compute this kind of minimal edit script.&lt;/p&gt;
&lt;p&gt;You can see it on the example by printing one array element per line and running an ordinary line diff:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;diff &amp;lt;(jq -cS '.users[]' before.json) &amp;lt;(jq -cS '.users[]' after.json)

0a1
&amp;gt; {&quot;id&quot;:4,&quot;name&quot;:&quot;Margaret&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One insertion before the first line: the right answer, with no key required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; it handles inserts and deletes anywhere without a schema, and preserves order information.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; matching uses exact equality, so an element that was both moved and edited appears as one deletion plus one insertion. Reordering also shows up as delete/insert pairs rather than moves. It costs more: classic LCS is O(n &amp;times; m), and Myers is O((n + m) &amp;times; D), where D is the size of the difference, which is fast for small changes but slows down for very different arrays. When several alignments are equally short, which one is chosen depends on the implementation.&lt;/p&gt;

&lt;h2 id=&quot;compare-json-arrays-ignore-order&quot;&gt;Comparing JSON arrays as a set (ignore order)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; each array is treated as an unordered collection (strictly, a multiset if duplicates count). Elements that appear in both are ignored; the rest are reported as added or removed. The example again produces one &quot;added&quot; entry for Margaret.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; no key needed, and order differences disappear entirely. It is a good fit for tags, roles and permission lists.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; an edited element is reported as one removal plus one addition, with no field-level detail. Real order changes become invisible, and you must decide whether duplicates matter.&lt;/p&gt;

&lt;h2 id=&quot;json-array-diff-strategies-compared&quot;&gt;JSON array diff strategies compared&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Strategy&lt;/th&gt;&lt;th&gt;Matches elements by&lt;/th&gt;&lt;th&gt;Front insertion in the example&lt;/th&gt;&lt;th&gt;Needs a key?&lt;/th&gt;&lt;th&gt;Typical cost&lt;/th&gt;&lt;th&gt;Main drawback&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;By index&lt;/td&gt;&lt;td&gt;Position&lt;/td&gt;&lt;td&gt;6 changed + 1 added&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;td&gt;Linear&lt;/td&gt;&lt;td&gt;Cascading &quot;changed&quot; noise; no move detection&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;By identity key&lt;/td&gt;&lt;td&gt;A field such as &lt;code&gt;id&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1 added&lt;/td&gt;&lt;td&gt;Yes, unique and present&lt;/td&gt;&lt;td&gt;Roughly linear (hashing)&lt;/td&gt;&lt;td&gt;Schema-specific; hides reordering&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sequence alignment (LCS / Myers)&lt;/td&gt;&lt;td&gt;Longest run of equal elements&lt;/td&gt;&lt;td&gt;1 inserted at [0]&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;td&gt;O(n &amp;times; m), or O((n + m) &amp;times; D)&lt;/td&gt;&lt;td&gt;Edited + moved items become delete + insert&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;As a set (ignore order)&lt;/td&gt;&lt;td&gt;Equal values anywhere&lt;/td&gt;&lt;td&gt;1 added&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;td&gt;O(n log n) sort or hashing&lt;/td&gt;&lt;td&gt;Edits become remove + add; order is lost&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No strategy is best everywhere. Index comparison is the most predictable, identity keys give the most readable output when you have a reliable key, and set comparison is right only when order genuinely carries no meaning.&lt;/p&gt;

&lt;h2 id=&quot;tips-for-cleaner-array-diffs&quot;&gt;Practical tips for a cleaner JSON array diff&lt;/h2&gt;
&lt;h3&gt;Sort arrays before comparing when order is not meaningful&lt;/h3&gt;
&lt;p&gt;Normalizing both documents turns an index-based diff into an order-insensitive one. For a top-level array of objects:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jq 'sort_by(.id)' before.json &amp;gt; before.sorted.json
jq 'sort_by(.id)' after.json  &amp;gt; after.sorted.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For an array nested inside a document, update it in place; &lt;code&gt;-S&lt;/code&gt; also sorts object keys, which helps if you will use a text diff afterwards:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jq -S '.users |= sort_by(.id)' before.json &amp;gt; before.sorted.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After sorting, the example produces a single entry in an index-based diff: added &lt;code&gt;$.users[3]&lt;/code&gt; with Margaret's record. For arrays of plain values, use &lt;code&gt;jq 'sort'&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;Compare keyed maps instead of arrays&lt;/h3&gt;
&lt;p&gt;If elements have a unique id, converting the array to an object gives you identity matching in any tool that diffs objects. In jq 1.6 or later:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jq '.users |= INDEX(.id)' before.json &amp;gt; before.keyed.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This produces &lt;code&gt;{&quot;users&quot;: {&quot;1&quot;: {...}, &quot;2&quot;: {...}, &quot;3&quot;: {...}}}&lt;/code&gt;, and the diff path for the new user becomes &lt;code&gt;$.users[&quot;4&quot;]&lt;/code&gt;. The same idea in Python:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json

def by_id(path):
    with open(path) as f:
        return {u[&quot;id&quot;]: u for u in json.load(f)[&quot;users&quot;]}

old, new = by_id(&quot;before.json&quot;), by_id(&quot;after.json&quot;)
print(&quot;added:&quot;, sorted(new.keys() - old.keys()))
print(&quot;removed:&quot;, sorted(old.keys() - new.keys()))
print(&quot;changed:&quot;, sorted(k for k in old.keys() &amp;amp; new.keys() if old[k] != new[k]))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check for duplicate ids first; with either approach, a later element with the same id overwrites an earlier one.&lt;/p&gt;

&lt;h3&gt;Append rather than prepend in fixtures&lt;/h3&gt;
&lt;p&gt;If you control the data, such as test fixtures, snapshot files or seed data, add new items at the end. Appends produce exactly one &quot;added&quot; entry in an index-based diff, so reviews stay short and unrelated entries do not churn.&lt;/p&gt;

&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;
&lt;p&gt;Paste the two documents from this article into &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; to see index-based comparison in action, then sort both by &lt;code&gt;id&lt;/code&gt; with jq and compare again to watch seven entries collapse to one. Everything runs in your browser tab, and nothing you paste is uploaded. If your arrays are collections rather than sequences, normalize them first; if order matters, a position-based diff tells you exactly what is at each index.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Reading JSON paths in a diff: $.settings.theme, $.members[2] and friends</title>
    <id>https://forgehub.link/blog/reading-json-paths/</id>
    <link rel="alternate" type="text/html" href="https://forgehub.link/blog/reading-json-paths/"/>
    <published>2026-10-15T00:00:00Z</published>
    <updated>2026-10-15T00:00:00Z</updated>
    <summary>How to read JSON path notation in diff output: $ for the root, dots for keys, zero-based [n] indexes, quoted brackets, and JSONPath vs JSON Pointer.</summary>
    <category term="JSON"/>
    <category term="JSONPath"/>
    <category term="Diff"/>
    <category term="Developer tools"/>
    <content type="html">&lt;p class=&quot;lede&quot;&gt;A JSON path in a diff tells you exactly where a change happened. &lt;code&gt;$&lt;/code&gt; is the root of the document, &lt;code&gt;.name&lt;/code&gt; steps into an object key, &lt;code&gt;[2]&lt;/code&gt; steps into an array at a zero-based index, and &lt;code&gt;[&quot;odd key&quot;]&lt;/code&gt; is used when a key is not a simple identifier. So &lt;code&gt;$.members[2]&lt;/code&gt; means &quot;the third item of the &lt;code&gt;members&lt;/code&gt; array at the top level&quot;.&lt;/p&gt;

&lt;p&gt;This guide explains the notation as it appears in &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; and most other JSON diff tools, shows how it maps to JSON Pointer, jq, JavaScript and Python, and how to jump from a path in a report straight to the value in your own data.&lt;/p&gt;

&lt;h2 id=&quot;what-a-json-diff-path-looks-like&quot;&gt;What a JSON diff path looks like&lt;/h2&gt;

&lt;p&gt;Take this pair of documents:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// Original
{&quot;project&quot;:&quot;JSON Mirror&quot;,&quot;version&quot;:&quot;1.0&quot;,&quot;settings&quot;:{&quot;theme&quot;:&quot;light&quot;,&quot;notifications&quot;:true},&quot;members&quot;:[&quot;Alex&quot;,&quot;Sam&quot;],&quot;status&quot;:&quot;draft&quot;}

// Updated
{&quot;project&quot;:&quot;JSON Mirror&quot;,&quot;version&quot;:&quot;1.1&quot;,&quot;settings&quot;:{&quot;theme&quot;:&quot;dark&quot;,&quot;notifications&quot;:true},&quot;members&quot;:[&quot;Alex&quot;,&quot;Sam&quot;,&quot;Jordan&quot;],&quot;published&quot;:true}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Comparing them produces five changes, each identified by a path:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;changed&lt;/strong&gt; &lt;code&gt;$.version&lt;/code&gt;: &lt;code&gt;&quot;1.0&quot;&lt;/code&gt; became &lt;code&gt;&quot;1.1&quot;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;changed&lt;/strong&gt; &lt;code&gt;$.settings.theme&lt;/code&gt;: &lt;code&gt;&quot;light&quot;&lt;/code&gt; became &lt;code&gt;&quot;dark&quot;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;added&lt;/strong&gt; &lt;code&gt;$.members[2]&lt;/code&gt;: &lt;code&gt;&quot;Jordan&quot;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;removed&lt;/strong&gt; &lt;code&gt;$.status&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;added&lt;/strong&gt; &lt;code&gt;$.published&lt;/code&gt;: &lt;code&gt;true&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read each path left to right as a set of directions: start at the root, then follow each step. &lt;code&gt;$.settings.theme&lt;/code&gt; means &quot;root, then key &lt;code&gt;settings&lt;/code&gt;, then key &lt;code&gt;theme&lt;/code&gt;&quot;.&lt;/p&gt;

&lt;h2 id=&quot;the-root-symbol&quot;&gt;The $ root symbol&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;$&lt;/code&gt; comes from JSONPath, a query notation that has been used informally for years and was standardized as &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9535&quot;&gt;RFC 9535&lt;/a&gt; in February 2024. In JSONPath, &lt;code&gt;$&lt;/code&gt; is the root node: the entire parsed document.&lt;/p&gt;

&lt;p&gt;If the whole document is a single primitive, such as &lt;code&gt;42&lt;/code&gt; or &lt;code&gt;&quot;hello&quot;&lt;/code&gt;, there is nothing to step into. A change from &lt;code&gt;42&lt;/code&gt; to &lt;code&gt;43&lt;/code&gt; is reported at the path &lt;code&gt;$&lt;/code&gt; alone. The same happens when the top-level type changes, for example from an object to an array: it is one change at &lt;code&gt;$&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;dot-notation-vs-bracket-notation&quot;&gt;Dot notation vs. bracket notation&lt;/h2&gt;

&lt;p&gt;Paths use three kinds of step.&lt;/p&gt;

&lt;h3&gt;Dot notation for identifier-like keys&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;$.settings.theme&lt;/code&gt; is the readable form for ordinary keys. JSON Mirror uses a dot only when the key matches the regular expression &lt;code&gt;/^[A-Za-z_$][\w$]*$/&lt;/code&gt;: a letter, underscore or dollar sign, followed by letters, digits, underscores or dollar signs. That covers &lt;code&gt;$.user_id&lt;/code&gt;, &lt;code&gt;$.createdAt&lt;/code&gt; and &lt;code&gt;$.$ref&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;Bracket index notation for arrays&lt;/h3&gt;

&lt;p&gt;Array elements are addressed by position in square brackets, counting from zero. &lt;code&gt;$.members[0]&lt;/code&gt; is &lt;code&gt;&quot;Alex&quot;&lt;/code&gt;, &lt;code&gt;$.members[1]&lt;/code&gt; is &lt;code&gt;&quot;Sam&quot;&lt;/code&gt;, and &lt;code&gt;$.members[2]&lt;/code&gt; is &lt;code&gt;&quot;Jordan&quot;&lt;/code&gt;. Steps chain naturally: &lt;code&gt;$.orders[0].items[3].sku&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caution: an index is a position, not an identity. JSON Mirror compares arrays by index, so inserting an item near the start shifts every later element and can produce several &quot;changed&quot; entries plus one &quot;added&quot; at the end. If your arrays hold records with IDs, read &lt;a href=&quot;/blog/json-array-diff-index-vs-identity/&quot;&gt;Comparing JSON arrays: by position or by identity?&lt;/a&gt; before trusting index paths.&lt;/p&gt;

&lt;h3&gt;Quoted bracket notation for unusual keys&lt;/h3&gt;

&lt;p&gt;Any key that fails the identifier test is written as a quoted string inside brackets. That includes keys with:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;spaces: &lt;code&gt;$[&quot;first name&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;dashes: &lt;code&gt;$[&quot;first-name&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;dots: &lt;code&gt;$[&quot;a.b&quot;]&lt;/code&gt; (this is one key named &lt;code&gt;a.b&lt;/code&gt;, not key &lt;code&gt;b&lt;/code&gt; inside key &lt;code&gt;a&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;a leading digit: &lt;code&gt;$[&quot;2fa&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;an empty name: the key &lt;code&gt;&quot;&quot;&lt;/code&gt; becomes &lt;code&gt;$[&quot;&quot;]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The quoted part uses JSON string escaping, so a key containing a double quote appears as &lt;code&gt;$[&quot;say \&quot;hi\&quot;&quot;]&lt;/code&gt;. The distinction between &lt;code&gt;$[&quot;a.b&quot;]&lt;/code&gt; and &lt;code&gt;$.a.b&lt;/code&gt; is the main reason brackets exist: without them, the two paths would look identical.&lt;/p&gt;

&lt;p&gt;A note on strictness: RFC 9535's dot shorthand allows letters, digits, underscores and non-ASCII characters, but not &lt;code&gt;$&lt;/code&gt;. If you paste a path like &lt;code&gt;$.$ref&lt;/code&gt; into a strict JSONPath implementation, write it as &lt;code&gt;$[&quot;$ref&quot;]&lt;/code&gt; instead. The bracket form is always valid.&lt;/p&gt;

&lt;h2 id=&quot;jsonpath-vs-json-pointer&quot;&gt;JSONPath vs. JSON Pointer&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6901&quot;&gt;JSON Pointer (RFC 6901)&lt;/a&gt; is the other common way to address a value. It is what JSON Schema &lt;code&gt;$ref&lt;/code&gt; fragments and JSON Patch operations use. The differences:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;JSONPath is a query language; it can also express wildcards and filters. A diff only uses its simplest form, a single concrete location.&lt;/li&gt;
  &lt;li&gt;JSON Pointer identifies exactly one value. The root is the empty string &lt;code&gt;&quot;&quot;&lt;/code&gt;, and each step is a &lt;code&gt;/&lt;/code&gt; followed by a key or index: &lt;code&gt;/members/2&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;JSON Pointer has no quoting. Instead, &lt;code&gt;~&lt;/code&gt; in a key is written &lt;code&gt;~0&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; is written &lt;code&gt;~1&lt;/code&gt;. A key named &lt;code&gt;a/b&lt;/code&gt; is &lt;code&gt;/a~1b&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Array indexes are zero-based in both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because JSON Pointer does not use dots as separators, keys like &lt;code&gt;a.b&lt;/code&gt; or &lt;code&gt;first-name&lt;/code&gt; need no special treatment: &lt;code&gt;/a.b&lt;/code&gt;, &lt;code&gt;/first-name&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;translation-table&quot;&gt;Translation table: JSONPath, JSON Pointer, jq, JavaScript, Python&lt;/h2&gt;

&lt;p&gt;The same locations in each notation. &lt;code&gt;data&lt;/code&gt; is the parsed document.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;Diff path (JSONPath)&lt;/th&gt;&lt;th&gt;JSON Pointer&lt;/th&gt;&lt;th&gt;jq&lt;/th&gt;&lt;th&gt;JavaScript&lt;/th&gt;&lt;th&gt;Python&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt; (empty)&lt;/td&gt;&lt;td&gt;&lt;code&gt;.&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$.settings.theme&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/settings/theme&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.settings.theme&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data.settings.theme&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;settings&quot;][&quot;theme&quot;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$.members[2]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/members/2&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.members[2]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data.members[2]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;members&quot;][2]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$[&quot;first-name&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/first-name&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.[&quot;first-name&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;first-name&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;first-name&quot;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$[&quot;a.b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/a.b&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.[&quot;a.b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;a.b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;a.b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$[&quot;a/b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/a~1b&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.[&quot;a/b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;a/b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;a/b&quot;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$[&quot;&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.[&quot;&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;&quot;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;$.$ref&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/$ref&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;.[&quot;$ref&quot;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data.$ref&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;data[&quot;$ref&quot;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two rows deserve a second look. The JSON Pointer &lt;code&gt;/&lt;/code&gt; is not the root; it points to the key named &lt;code&gt;&quot;&quot;&lt;/code&gt;, because the root pointer is the empty string. And in jq, &lt;code&gt;$&lt;/code&gt; starts a variable name, so a key like &lt;code&gt;$ref&lt;/code&gt; needs the bracket form.&lt;/p&gt;

&lt;h2 id=&quot;jump-from-a-diff-path-to-the-value&quot;&gt;How to jump from a diff path to the value&lt;/h2&gt;

&lt;p&gt;Once a diff has told you where something changed, you often want to look at the surrounding data or check the value in another file. Translate the path one step at a time.&lt;/p&gt;

&lt;h3&gt;With jq&lt;/h3&gt;

&lt;p&gt;jq paths look almost identical to JSONPath; drop the leading &lt;code&gt;$&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;jq '.members[2]' updated.json
&quot;Jordan&quot;

jq '.settings' original.json
{
  &quot;theme&quot;: &quot;light&quot;,
  &quot;notifications&quot;: true
}

jq '.[&quot;first-name&quot;]' user.json&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The single quotes above are for POSIX shells such as bash and zsh; other shells may need different quoting.&lt;/p&gt;

&lt;h3&gt;With JavaScript&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;const data = JSON.parse(text);
data.settings.theme;    // &quot;dark&quot;
data.members[2];        // &quot;Jordan&quot;
data[&quot;first-name&quot;];     // bracket form for unusual keys&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Since JSON Mirror's dot rule follows JavaScript identifier syntax for ASCII keys, any path it prints can be pasted after &lt;code&gt;data&lt;/code&gt; by replacing the &lt;code&gt;$&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;With Python&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;import json

with open(&quot;updated.json&quot;) as f:
    data = json.load(f)

data[&quot;settings&quot;][&quot;theme&quot;]   # 'dark'
data[&quot;members&quot;][2]          # 'Jordan'&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Python has no dot access for dictionaries, so every key step becomes &lt;code&gt;[&quot;key&quot;]&lt;/code&gt; and every index step stays &lt;code&gt;[n]&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;reading-added-and-removed-paths&quot;&gt;Reading paths on added and removed entries&lt;/h2&gt;

&lt;p&gt;The path of an &quot;added&quot; entry exists only in the updated document; the path of a &quot;removed&quot; entry exists only in the original. Running &lt;code&gt;jq '.status' updated.json&lt;/code&gt; on the example above prints &lt;code&gt;null&lt;/code&gt;, because jq returns &lt;code&gt;null&lt;/code&gt; for a missing key. That is easy to misread: a missing key and an explicit &lt;code&gt;null&lt;/code&gt; are different things, and JSON Mirror reports them differently. &lt;a href=&quot;/blog/json-null-vs-missing-keys/&quot;&gt;null vs. missing: the JSON difference that breaks APIs&lt;/a&gt; covers why that matters. In jq, &lt;code&gt;has(&quot;status&quot;)&lt;/code&gt; tells the two apart.&lt;/p&gt;

&lt;p&gt;When a whole object or array is added or removed, the diff shows one entry at the subtree's path containing the full value, rather than one entry per nested key.&lt;/p&gt;

&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;

&lt;p&gt;Paste two versions of a document into &lt;a href=&quot;/json-mirror/&quot;&gt;JSON Mirror&lt;/a&gt; and every change comes back with its path, a one-line explanation and the before and after values. Everything runs in your browser tab, and nothing is uploaded. Use the table above to turn any path into a jq filter or a line of code, and remember the two rules that trip people up most: indexes start at zero, and quoted brackets mean &quot;this is one key, read it literally&quot;.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>How to Export JSON Differences Beyond the 300-Result View</title>
    <id>https://forgehub.link/blog/export-json-differences-beyond-300-results/</id>
    <link rel="alternate" type="text/html" href="https://forgehub.link/blog/export-json-differences-beyond-300-results/"/>
    <published>2026-10-15T00:00:00Z</published>
    <updated>2026-10-15T00:00:00Z</updated>
    <summary>JSON Mirror shows up to 300 differences per filter. Learn what the exported report contains, how to refresh stale results, and which limits still apply.</summary>
    <category term="JSON Mirror"/>
    <category term="Guide"/>
    <content type="html">&lt;p class=&quot;lede&quot;&gt;The 300-result limit in JSON Mirror applies to the visible list, not to the exported report. After a successful comparison, Export report downloads the complete changes array. Choose the right original and updated documents, compare them again after any edit, and use the JSON file when you need every recorded difference.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/json-mirror/&quot;&gt;Open JSON Mirror&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;section-1&quot;&gt;The visible list and the report serve different purposes&lt;/h2&gt;
&lt;p&gt;JSON Mirror keeps the comparison result in memory as a changes array. Its screen renderer selects the current filter and draws only the first 300 matching entries. If more matches exist, a notice explains that the list is shortened and directs you to export the report. That notice describes a display boundary; it does not mean that comparison stopped after finding 300 differences.&lt;/p&gt;
&lt;p&gt;For example, a comparison with 305 added values can display 300 cards while the downloaded report still contains all 305 changes. Use the filters to inspect particular kinds of differences, and use the export when a later review needs the full recorded set. The app does not currently offer pagination through every result card.&lt;/p&gt;
&lt;h2 id=&quot;section-2&quot;&gt;Compare the intended inputs before exporting&lt;/h2&gt;
&lt;p&gt;Put the earlier document in Original and the later document in Updated, then select Compare JSON. The direction matters because the result describes additions and removals relative to those two inputs. If you swap them, compare again before exporting. The report should represent the comparison you actually intend to share or inspect.&lt;/p&gt;
&lt;p&gt;Editing either input after a comparison makes the displayed result stale. JSON Mirror disables the export control and asks you to compare again. Do not treat an older visible list as evidence that your latest edits were compared. Complete another successful comparison first; validation or comparison errors also leave exporting disabled.&lt;/p&gt;
&lt;h2 id=&quot;section-3&quot;&gt;Know what the downloaded file contains&lt;/h2&gt;
&lt;p&gt;Export report creates a JSON download named json-mirror-report.json. Its top-level data identifies the tool, records that array comparison is by index, and includes the in-memory report with a comparison timestamp and changes. The export handler uses that report directly rather than exporting just the currently selected screen filter.&lt;/p&gt;
&lt;p&gt;This distinction matters when you inspect only Added or Removed on screen. A filter helps you browse the comparison, but it does not turn the downloaded file into a filtered report. If another workflow requires only one change kind, inspect the downloaded changes array and select the relevant records in that workflow. JSON Mirror does not provide that separate export option.&lt;/p&gt;
&lt;h2 id=&quot;section-4&quot;&gt;Understand the limits an export does not remove&lt;/h2&gt;
&lt;p&gt;A complete export means the complete result of a successful comparison, not unlimited input support. Each editor has a one-million-character input limit. Comparisons may also fail when the supported nesting depth is exceeded. Exporting cannot bypass those checks or recover a comparison that never completed.&lt;/p&gt;
&lt;p&gt;The arrayComparison field is a reminder of another boundary: arrays are compared by index. A moved or inserted item can therefore produce several position-based differences. The downloaded file preserves that interpretation; it does not independently detect moves or match objects by an identity field. Read those changes in the context of your data.&lt;/p&gt;
&lt;h2 id=&quot;section-5&quot;&gt;Keep a report only when your workflow needs it&lt;/h2&gt;
&lt;p&gt;The app builds the download from a browser Blob when you choose Export report. This lets you decide when to create a file instead of treating every comparison as something to retain. Once you download or share the report, handle it according to the sensitivity of the values it contains. A local tool does not make a shared output harmless.&lt;/p&gt;
&lt;p&gt;For a repeatable review, confirm the input direction, run a fresh successful comparison, note any shortened-list notice, and download the report. Check that the changes array is the result you expected before passing the file onward. The comparedAt timestamp records when that comparison was made; it is not proof that the source documents are current.&lt;/p&gt;
&lt;p class=&quot;editorial-note&quot;&gt;AI-assisted ForgeHub editorial. App capabilities were checked against its implementation; this guide is not an independent review.&lt;/p&gt;</content>
  </entry>
</feed>
