JSON key ordering and where deterministic serialization matters

4 min read

JSON is famously easy to use, but the question “does the same data always serialize to the same string?” has a surprising answer. This article walks through how JSON treats key order in the spec versus in practice, and where you actually need ordering to be stable.

RFC 8259: key order is undefined

The JSON spec (RFC 8259) says of objects:

An object is an unordered collection of zero or more name/value pairs

By the spec, JSON objects do not carry order. {"a":1,"b":2} and {"b":2,"a":1} represent the same data.

Two serializations of “the same” object can differ as strings while being logically identical.

In practice, most implementations preserve insertion order

Modern JavaScript’s JSON.stringify outputs in object insertion order:

const obj = {};
obj.b = 2;
obj.a = 1;
JSON.stringify(obj); // → '{"b":2,"a":1}'

This is mandated by ES2015 for non-integer keys. Browsers agree. Python’s dict has guaranteed insertion order since 3.7.

So the situation is “the spec says unordered, but implementations preserve order anyway”, which sets a particular trap:

  • Tests pass in development — locally, objects are constructed the same way, so orders match.
  • Production occasionally breaks — when the same data arrives via a different path, orders differ.

Where stable ordering becomes necessary

The property of “the same data always produces the same string” is deterministic (canonical) serialization, and these are the cases where you need it.

1. Signing and hashing

When you sign JSON, the string under signature must be unique:

// ❌ unstable serialization breaks signing
const data = { name: 'Alice', age: 30 };
const signature = sign(JSON.stringify(data), key);

// receiver
JSON.stringify(receivedData) === '{"name":"Alice","age":30}' ? verifyOK : verifyFail;

JWT signs claims as their JSON string, so issuer and verifier must use the same serializer. In practice both ends use the same library, so this rarely breaks — until a third-party implementation joins the loop.

JWS (RFC 7515) sidesteps the issue by saying the signature input is the bytes as transmitted. Servers do not re-serialize received JSON; they sign/verify the bytes they got.

2. Cache key generation

Hashing an object into a cache key requires the same object to hash identically:

// ❌ same data, different cache keys
const k1 = hash(JSON.stringify({ a: 1, b: 2 })); // → "x..."
const k2 = hash(JSON.stringify({ b: 2, a: 1 })); // → "y..."  (different)

Sort the keys before serializing:

function canonicalStringify(obj) {
	if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
	if (Array.isArray(obj)) return `[${obj.map(canonicalStringify).join(',')}]`;
	const keys = Object.keys(obj).sort();
	return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`).join(',')}}`;
}

3. Configuration file diffs

Saving a JSON config in Git, with key order shifting on each save, produces noisy diffs.

If your CI auto-formats config files, alphabetizing keys keeps the diff stable across editors. There’s no prettier --order-keys baked in, so common practice is a custom formatter or jq -S . before commit.

4. Blockchain transactions

Blockchain protocols require “the same transaction always hashes to the same value”, so JSON-based protocols mandate canonical serialization. Bitcoin’s transactions are binary, but Ethereum’s JSON-RPC and IPLD (Filecoin etc.) use canonical JSON.

The industry standard: JCS (RFC 8785)

JCS (JSON Canonicalization Scheme), RFC 8785, was published in 2020 as the canonical JSON spec.

JCS rules in brief:

  1. Sort keys by UTF-16 code point.
  2. No whitespace (no spaces around commas or colons).
  3. Numbers serialize per ECMAScript Number.toString() semantics.
  4. Strings are UTF-8 with minimal escaping.

JCS-compliant libraries exist for Java, Python, JavaScript, and others, and are used in cryptographic signing and verification.

How much should you care?

Whether to enforce determinism depends on the use case:

Use caseDeterminism needed?
Configuration fileYes (cleaner diffs)
API responseNo
Signature inputYes
Cache keyYes
Log outputProbably no (favor readability)
Database storageNo

If you find code that compares two values via JSON.stringify, ask whether the inputs are stably serialized. It’s a common bug source.

Summary

  • JSON spec leaves key order undefined.
  • Implementations broadly preserve insertion order, which gives a false sense of stability.
  • For signing, cache keys, and config diffs, enforce canonical serialization.
  • JCS (RFC 8785) is the cross-language reference for canonical JSON.

To play with key ordering, the JSON formatter on this site supports a sort-keys option. Feed it {"b":2,"a":1} to see how the hash of the formatted output changes after sorting.