How to Compare Two JSON Objects When Key Order Differs
Two payloads with identical data can compare unequal because of key order. Where that breaks stringify, hashing and signatures — and the three reliable ways to fix it.
- #JSON
- #JSON Diff
- #API Testing
- #Developer Tools
The webhook verification worked for six months.
Then a dependency upgrade changed how one service serialized its payload. Same fields, same values, different order. Every signature check started failing, and nothing in the data had actually changed.
That is the whole problem in one sentence: a JSON object is unordered, but the bytes are not.
const a = { name: "Alice", age: 30 };
const b = { age: 30, name: "Alice" };
JSON.stringify(a) === JSON.stringify(b); // false
Same object. Different serialization. A comparison that says "different".
Why the order changes at all
RFC 8259 defines an object as an unordered collection of name/value pairs. Nothing in the format promises that keys come back in the order you sent them.
In practice the order changes because:
- Different languages serialize properties in their own order (Python inserts, Go sorts map keys, some runtimes sort numeric keys first).
- A proxy, cache or database re-stores the document. PostgreSQL
jsonband MySQLJSONboth normalise key order on write. - A library upgrade changes an internal serializer.
So a payload can round-trip through three systems and come back identical in meaning and different in bytes.
The four ways people compare JSON
| Approach | Same data, different order | Good for |
|---|---|---|
JSON.stringify(a) === JSON.stringify(b) | ❌ reports a difference | Nothing, as a comparison |
Deep equality (assert.deepStrictEqual, lodash.isEqual, Python ==) | ✅ equal | Tests, assertions |
| Sort keys, then compare or hash | ✅ equal | Caches, dedup, checksums |
| Canonicalize (RFC 8785 JCS), then sign | ✅ equal | Signatures, interop |
Most bugs come from using the first row without realising it is the first row.
JSON.stringify is not a comparison
It serializes, and serialization follows property order. It also drops keys whose value is undefined, converts Date to a string, and reorders integer-like keys:
JSON.stringify({ b: 1, 2: 2, 1: 3 });
// '{"1":3,"2":2,"b":1}'
Useful as an implementation detail. Useless as an equality check.
Deep equality is the right default
If the question is "are these the same data", parse both sides and compare structurally. Every mainstream language does this correctly:
import assert from "node:assert/strict";
assert.deepStrictEqual(JSON.parse(left), JSON.parse(right)); // passes
assert json.loads(left) == json.loads(right) # passes
No sorting, no canonicalization. Object key order is ignored, array order is not — which is exactly what the spec says.
Use jq -S when you want to see it in a terminal:
diff <(jq -S . before.json) <(jq -S . after.json)
-S sorts keys on output, so a pure reorder produces an empty diff.
Sorting keys is how you get a stable hash
This is where the reorder bug actually costs money. A content hash over unsorted JSON is not a content hash — it is a byte hash.
import { createHash } from "node:crypto";
const hash = (value) =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
hash(a) === hash(b); // false — same data, different key order
Every cache key, ETag, dedup check and change-detection job built like this will treat a reorder as a new document. The cache misses, the "changed" webhook fires, the sync job re-uploads the same rows.
The fix is to sort keys at every level before hashing:
const sortDeep = (value) => {
if (Array.isArray(value)) return value.map(sortDeep);
if (value && typeof value === "object") {
return Object.keys(value)
.sort()
.reduce((acc, key) => ((acc[key] = sortDeep(value[key])), acc), {});
}
return value;
};
hash(sortDeep(a)) === hash(sortDeep(b)); // true
If you would rather not maintain that, fast-json-stable-stringify does the same thing.
Canonicalization is the version standards agree on
For signatures and cross-language interop, "sort the keys" is not quite enough — number formatting and string escaping also have to be fixed. RFC 8785 (JSON Canonicalization Scheme) defines exactly how, and libraries exist for every major language.
That matters because a signature covers bytes:
- Webhook HMAC — signed over the raw request body.
- JWS / JWT payloads — the signature covers the base64url of the exact JSON string.
- Config attestation — a checksum that has to match across services.
If any of these producers reorder keys, verification breaks even though the data is identical. Canonicalize on both sides before signing or verifying, and the problem disappears.
Where order genuinely matters
Unordered objects do not mean order is irrelevant everywhere:
- Arrays are ordered.
[1,2]and[2,1]are different documents, and always will be. - Serialized output is ordered. If you write a config file, the key order in the file is whatever your serializer chose. That affects diffs in code review, nothing more.
- Signature input is ordered. See above — the bytes are the contract.
Sort keys when the goal is comparison or hashing. Do not sort when the goal is human-readable output that should keep a deliberate order.
A short decision list
- Comparing two documents in a test → deep equality, no preprocessing.
- Comparing two API responses by eye → parse both, then diff paths. The JSON compare tool does that in the browser.
- Hashing for cache or dedup → sort keys recursively, then hash.
- Signing or verifying → RFC 8785 canonicalization, not ad-hoc sorting.
- Reading a minified payload → format it first with the JSON formatter, so both sides start from the same shape.
Two caveats before you trust a clean result
Sorting fixes key order. It does not fix everything:
- Precision. Integers above 2^53 are rounded when JavaScript parses them, so two different IDs can compare equal. This is covered in more detail in why JSON diffs miss changes.
- Duplicates.
{"a":1,"a":2}parses to{"a":2}in most runtimes, so the first value disappears before any comparison runs. Sorting will not bring it back.
If either applies, compare the raw text or use a parser that preserves number tokens.
Frequently asked questions
Does key order matter when comparing JSON?
No. Objects are unordered in RFC 8259, so {"a":1,"b":2} and {"b":2,"a":1} are the same document. Deep equality handles this correctly without any preprocessing.
Why does JSON.stringify(a) === JSON.stringify(b) return false?
Because it compares strings, and the strings differ in property order. It is a serialization check, not an equality check.
Is sorting keys enough for hashing?
Yes, provided you sort at every nesting level and the values themselves are stable. Numbers and string escaping can still differ (RFC 8785 covers that), so use a canonicalization library if the hash has to match across languages.
Does PostgreSQL or MySQL preserve JSON key order?
No. jsonb in PostgreSQL and JSON in MySQL normalise the document, so key order is not preserved and duplicate keys are resolved on write. Equality inside the database is order-insensitive, which is usually what you want.
What about arrays of objects?
Array order is meaningful, so a reordered array is a real change. If the array is really a set, sort it by a stable field such as id before comparing — that is a decision about your data, not a tool setting.
The rule worth remembering: compare data with a parser, compare bytes with a canonical form. Anything in between is a bug waiting for a library upgrade.
When you want to see exactly which paths differ, compare two JSON objects here — key order is ignored, paths are reported, and nothing is uploaded.
