Why Your JSON Diff Misses Changes — And Reports Ones That Aren't Real
A JSON diff invents differences that are only formatting, and hides real ones like 64-bit IDs and duplicate keys. Here is what to check before you trust it.
- #JSON
- #JSON Diff
- #API Testing
- #Developer Tools
A staging endpoint and a production endpoint returned the same order. Same items, same total, same timestamp.
The diff said 47 lines changed.
The real change was one field: currency went from "usd" to "USD". Everything else was indentation, because staging pretty-prints the payload and production does not.
That is the ordinary experience of diffing JSON. But it hides a second, worse problem.
A diff can report changes that are not changes. A diff can also miss changes that are real. The first one is noisy and annoying. The second one ships bugs, and nothing tells you it happened.
The short version: text diffs lie about formatting, structural diffs lie about data. You need to know which lie you are exposed to.
Two kinds of wrong
| Failure | What you see | What actually happened |
|---|---|---|
| False positive | 47 lines changed | One value changed; the rest is whitespace |
| False negative | No differences found | Two large IDs differ above 2^53 and were rounded away |
The first failure is solved by parsing the JSON before comparing. The second is not solved by anything unless you know where to look — which is the rest of this article.
The noise: changes that are not changes
A structural compare parses both documents into data first, so these stop being differences:
- Indentation and whitespace. Two spaces versus four is not a change to the data.
- Key order.
{"a":1,"b":2}and{"b":2,"a":1}are the same object. RFC 8259 does not define order for object members. - Number spelling.
1,1.0and1e0are the same JSON number. A text diff flags all three. - Escaped characters.
"\u00e9"and"é"decode to the same string. - Line endings and the trailing newline. CRLF versus LF rewrites every line in a text diff.
- A byte-order mark. Some editors add one;
JSON.parserejects it, and some tools strip it silently. - Timestamp offsets.
"2026-11-03T10:00:00Z"and"2026-11-03T18:00:00+08:00"are the same instant written two ways.
If your diff is showing any of these, you are comparing text, not JSON. The JSON compare tool parses both sides first, so reordering a key or reformatting a payload reports nothing.
That solves the noise. It does not solve the next part.
The blind spots: changes a diff misses
These are the ones that cost you a deploy. They all share a cause: by the time the comparison runs, the information needed to detect the change is already gone.
Integers above 2^53
JSON has no size limit on numbers. JavaScript does. Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991, and anything larger is stored as a float and rounded.
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
Now compare those two payloads in any tool built on JSON.parse:
JSON.parse('{"id": 9007199254740993}').id ===
JSON.parse('{"id": 9007199254740992}').id
// true
Two different IDs. One comparison. Zero differences reported.
This is not exotic. Snowflake-style IDs, Discord and Twitter IDs, database bigint primary keys and money stored in minor units all live above that line. It is also why a "no changes" result on an order payload deserves a second look when the order contains a 19-digit ID.
The fix is to compare those values as strings, or to parse with a library that preserves the raw number token instead of converting it to a double. A diff tool that says "identical" is telling you about its own parser, not about your data.
Duplicate keys
RFC 8259 says names within an object should be unique. It does not say what happens when they are not, and implementations disagree. Most keep the last one.
JSON.parse('{"a":1,"a":2}')
// { a: 2 }
So {"a":1,"a":2} and {"a":2} compare as identical, even though the bytes are different and the producer is emitting something it should not. If you are debugging a serializer that writes duplicate keys, a structural diff will never show it to you. You have to validate the raw text.
null versus a missing key
These are different documents:
{ "name": "Alice", "phone": null }
{ "name": "Alice" }
A structural compare reports them correctly as a change. But a merge-patch pipeline does not: RFC 7386 defines null in a merge patch as delete this key. So {"phone": null} and {} collapse into the same instruction, and an explicit "clear the phone number" becomes indistinguishable from "this field was never set".
If your config or API uses merge patch, that distinction has to be enforced before the patch is applied, not after.
Unicode normalization
"é" has two valid encodings:
- U+00E9, a single precomposed character
- U+0065 + U+0301,
efollowed by a combining acute accent
They render identically. They are different strings, and "é" === "e\u0301" is false. macOS filesystems and some databases hand you the decomposed form; most web forms produce the composed one.
A structural diff reports a change. A human reading the output says "those are the same name". Both are correct, and neither is useful.
The fix is to normalize to NFC on both sides before comparing — but only if your comparison is about human-visible text. If you are comparing tokens or hashes, normalizing hides a real difference.
Arrays compared by index
Structural compare treats arrays as ordered, which matches JSON semantics. That is correct and also occasionally wrong for your intent.
["draft","published"] and ["published","draft"] are different documents. If your array is really a set of tags, the diff reports a change that does not matter, and if you "fix" it by sorting both sides first, you lose the ability to detect a reorder that does matter — like the order of steps in a migration.
Decide per field whether order is part of the contract. There is no tool setting that can guess this for you.
Re-serialization artifacts
If a tool parses and re-stringifies before diffing, you inherit every formatting decision of that serializer. A float that came in as 0.1 can leave as 0.1, but a computed 0.1 + 0.2 leaves as 0.30000000000000004. Comparing a payload that went through that round trip against the original can produce differences that exist in no source file.
Compare parsed structures where possible. Compare raw text when the exact bytes are the contract.
Compare, diff, and patch are three different jobs
The word "diff" gets used for all three, and that is where most confusion starts.
| You want to know | The job | The output |
|---|---|---|
| Are these two documents equivalent? | Compare | true or false |
| What changed, for a human to read? | Diff | A list of paths with before and after |
| What operations turn A into B? | Patch | Machine-applicable JSON (RFC 6902) |
A readable diff is for review. It is not something you feed to code — the text is prose. If you need code to apply the change, you want JSON Patch, which is a list of operations:
[
{ "op": "replace", "path": "/user/name", "value": "Bob" },
{ "op": "remove", "path": "/user/phone" },
{ "op": "add", "path": "/user/timezone", "value": "Europe/Berlin" }
]
That is the same three differences, expressed as instructions instead of a report. The test operation exists too, which lets a patch fail loudly if a precondition no longer holds.
If all you need is "did this response change", neither output is right. Assert deep equality in your test and move on. Reading a diff to answer a yes/no question is where people lose an afternoon.
A checklist before you trust a diff
- Parse both sides with the same parser. Different parsers disagree about duplicate keys, big numbers and BOM handling.
- Preserve large numbers if IDs, hashes or money are involved. Do not hand them to a double.
- Decide what order means for each array. Ordered list or set?
- Normalize Unicode if the text comes from more than one system.
- Compare timestamps as instants, not as strings.
- Check the raw text when you need to prove a serializer is not emitting duplicate keys.
- Pick the output first. Boolean for tests, readable diff for review, patch for automation.
None of this makes the tool wrong. It makes the tool's assumptions visible, which is the only way to know when a clean result means "nothing changed" and when it means "nothing detectable changed".
Frequently asked questions
Does key order matter in JSON?
No. Object members are unordered in RFC 8259, so {"a":1,"b":2} and {"b":2,"a":1} are the same object. Array order does matter — [1,2] and [2,1] are different documents.
Is 1.0 the same as 1 in JSON?
As a value, yes. Both are the number one, and a structural compare reports no difference. The lexical form differs, so a text diff reports a change.
Why does my diff show every line changed after formatting?
Because it is comparing text. Reformatting rewrites whitespace on every line without changing any value. A structural compare reports nothing.
Why does my JSON diff say the documents are identical when I know they differ?
The most common cause is a number larger than 2^53 that was rounded by the parser before comparison. Duplicate keys and Unicode normalization are the other two. Check those three before you assume the tool is broken.
Is it safe to paste an API response into an online JSON diff?
Only if the service does not upload it. Payloads commonly contain session tokens, customer records and internal endpoints. The JSON compare tool parses and compares in the browser, so the document never leaves the tab — but that property has to be true of whichever tool you pick, not assumed.
Should I use JSON Patch or a diff tool?
Patch if code applies the change. Diff if a person reviews it. If you only need a yes/no answer, use neither — assert equality in a test.
The useful habit is not "always use a structural compare". It is knowing that every comparison throws information away, and choosing which information you can afford to lose.
When you do want to read a diff rather than assert on it, compare two JSON documents here — paths, before and after, and nothing uploaded. If the payload arrives minified and unreadable, run it through the JSON formatter first, so both sides reach the comparison in the same shape.
