Have you ever watched two Python processes hash “the same” payload and still miss the cache? I spent forty-eight hours on that disagreement, and every pretty-printed log still looked identical to me. One service built a nested dict from an HTTP JSON body. The other assembled the same shape from keyword arguments, following a dataclass field order. Both objects compared equal with ==. Only the cache key refused to agree.
I am writing field notes, not a victory lap. What I tried, what broke, and what I would repeat are below, with a small test you can run without any special hardware. If you already canonicalize with sort_keys=True, separators, and a nested walk, you can skim the artifact and jump to the limitations.
Hour 0–8: I blamed the cache backend
I started where a lot of us start: Redis, TTL, maybe a silent eviction. I dumped GET and SET traces until the terminal felt like a crime scene. The keys were different strings, not missing values. That should have killed the backend theory immediately, right?
It did not, because I still wanted infrastructure to be guilty. I compared key prefixes, clock skew, and even the colon in user:42:payload. The prefix was stable. The digest after the prefix was not. Two equal Python dicts were producing two hashes, and I kept staring at Redis like it had opinions.
Commands I actually ran, with the secrets stripped:
redis-cli MONITOR | grep 'user:42:'
python -c "import json; print(json.dumps({'b': 1, 'a': 2}))"
python -c "import json; print(json.dumps({'a': 2, 'b': 1}))"
Those two json.dumps lines already disagree. Did you notice the key order in the output? I did not, not for the first evening, because I was still reading the dict with my eyes instead of reading the bytes.
Hour 8–20: I blamed insertion order, then stopped too early
Python dicts have been insertion-ordered for years, so this is not a random-hash-seed ghost. json.dumps(obj) without sort_keys=True is a serializer, not a canonical form. HTTP JSON usually arrives with one key order. In-process construction often follows field order from a class. == does not care. Your digest does.
I “fixed” it with sort_keys=True and went to bed. That is the part I would not repeat. Nested dicts were sorted, yes, but I still had lists of dicts, optional keys that appeared only on one path, and a datetime that one caller had already turned into a string. Can a one-line dumps call hold all of that? Not in the code I shipped that night.
What broke after the first fix:
- A list of tag objects hashed differently when one producer sorted by
idand the other kept request order. -
Noneversus a missing key produced different JSON, even though my mental model said “optional.” -
Trueand1are not the same JSON value, but a sloppy equality check in a test made me doubt that. - Default dumps spacing (
', 'and': ') bit me once I mixed compact dumps from another helper.
Hour 20–36: I asked a model, then I asked a clean machine
I pasted the helper into a coding assistant and asked for a “stable cache key.” The first snippet used json.dumps(data, sort_keys=True) and nothing else. It looked confident. It was also incomplete, which is on me for accepting a one-liner as a contract.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode’s free model access to generate alternate canonicalizers, then ran the same fixture on its free server option so I could compare digests against my laptop. I am not attaching model names, quotas, or hardware claims I cannot verify. The useful part was boring: same fixture file, two machines, print the hex digest, refuse to guess.
The remote box immediately showed a second failure I had papered over locally. My laptop tests imported a helper that mutated the payload by filling default keys. The clean run did not import that helper. Guess which hash the production worker matched?
The artifact: a canonicalizer you can break on purpose
Label this as a worked example, not a library. It walks nested dicts and lists, drops explicit None values, sorts object keys, and refuses types that JSON cannot represent without a policy. If you need datetimes, decide on a single string format before you get here.
# canonical_cache_key.py
from __future__ import annotations
import hashlib
import json
from typing import Any
class CanonicalizeError(TypeError):
pass
def _strip_none(value: Any) -> Any:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if not isinstance(key, str):
raise CanonicalizeError(f"non-string key: {key!r}")
if item is None:
continue
cleaned[key] = _strip_none(item)
return cleaned
if isinstance(value, list):
return [_strip_none(item) for item in value]
if isinstance(value, tuple):
raise CanonicalizeError("tuples are not canonical; use a list")
if isinstance(value, (str, int, float, bool)):
return value
raise CanonicalizeError(f"unserializable type: {type(value)!r}")
def canonical_dumps(value: Any) -> str:
cleaned = _strip_none(value)
return json.dumps(
cleaned,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
def cache_key(prefix: str, value: Any) -> str:
body = canonical_dumps(value)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
return f"{prefix}:{digest}"
And a test file that would have saved me the second night:
# test_canonical_cache_key.py
from canonical_cache_key import CanonicalizeError, cache_key, canonical_dumps
import pytest
def test_key_order_does_not_matter():
a = {"b": 1, "a": {"y": 2, "x": 3}}
b = {"a": {"x": 3, "y": 2}, "b": 1}
assert canonical_dumps(a) == canonical_dumps(b)
assert cache_key("user", a) == cache_key("user", b)
def test_none_and_missing_are_the_same():
a = {"name": "ada", "title": None}
b = {"name": "ada"}
assert canonical_dumps(a) == canonical_dumps(b)
def test_list_order_is_significant():
a = {"tags": [{"id": 1}, {"id": 2}]}
b = {"tags": [{"id": 2}, {"id": 1}]}
assert canonical_dumps(a) != canonical_dumps(b)
def test_rejects_datetime_until_you_choose_a_format():
import datetime
payload = {"ts": datetime.datetime(2026, 9, 13, 12, 0, 0)}
with pytest.raises(CanonicalizeError):
canonical_dumps(payload)
Run it like this:
python -m pytest test_canonical_cache_key.py -q
If you want the “two machines” check without pytest, print the dumps string and the digest on each host and diff the text. Do not diff repr(dict). That representation is for humans who already believe the objects are equal.
python - <<'PY'
from canonical_cache_key import canonical_dumps, cache_key
payload = {"b": 1, "a": {"y": 2, "x": 3}, "title": None}
print(canonical_dumps(payload))
print(cache_key("user", payload))
PY
Decision table I wish I had on hour two
| Symptom | What I tried first | What actually differed | Repeatable check |
|---|---|---|---|
Cache miss with == True |
Restart Redis | Key insertion order in json.dumps
|
Dump both strings, do not print dicts |
Miss after sort_keys=True
|
Blame floats | List-of-objects order, or None vs missing |
Fixture with both producers |
| Local tests green, worker misses | Blame env vars | Import-time mutation of the payload | Run the fixture in a clean process |
| Unicode keys look fine | Blame encoding |
ensure_ascii True vs False |
Compare UTF-8 bytes of the dumps |
| Compact vs pretty helpers mixed | Blame hashing algo | Default spaces in dumps separators | Pin separators=(",", ":")
|
What I would repeat, and what I would not
I would repeat printing the exact cache-key string from both producers before I open the cache UI. I would repeat a clean-process run, because helpers that “helpfully” fill defaults are still mutations. I would repeat refusing datetimes and tuples until the team writes the policy down. Would I repeat trusting a one-line dumps call because it looked professional? No.
I would also repeat keeping the canonicalizer tiny. Once you start special-casing floats, you are one rounding policy away from a second cache namespace. Money, latitude, and timestamps do not belong in a generic JSON dump unless every producer already converted them to strings.
Numbered loop I now use when a digest disagrees:
- Log the raw dumps text from both sides, not a pretty dict.
- Confirm both sides import the same canonicalizer module, not a cousin helper.
- Check list order,
Noneversus missing, and bool versus int. - Run the same fixture in a process that did not import application defaults.
- Only then look at Redis, TTL, or the hash algorithm.
Limitations, and who should not use this
This approach assumes JSON-shaped data: string keys, lists where order is part of meaning, and no NaN. It is the wrong tool for content-addressed files, for protobuf, or for payloads that include unordered sets. If two lists are semantically a set of tags, sort them with an explicit key before canonicalization. Do not hide that sort inside dumps and hope the next reader notices.
Do not use this helper as a security boundary. SHA-256 of canonical JSON is a cache index, not an authenticity check. Do not put secrets in the payload and then log the dumps string. Do not call this “deterministic” if any producer still emits local timestamps, random IDs, or iteration over a set.
People who should skip this pattern: anyone hashing already-canonical bytes (a stored JSON document with a frozen key order), anyone on a wire format that is not JSON, and anyone who needs streaming keys for multi-gigabyte objects. If your cache key is a primary key plus a schema version, you may not need a digest at all. Why hash a document when invoice:9981:v3 already names the thing?
Floats remain a hole. allow_nan=False stops NaN, but 1.0 and 1.00 are the same JSON number while binary floats from two languages may not round-trip the way you expect. If the field is money, send a decimal string. If the field is a measurement, pick a rounding rule and apply it before this function sees the value.
Closing the notebook
Forty-eight hours later, the cache was not haunted. json.dumps had done exactly what it always does: it serialized the dict it was given, in the order it was given, with the spacing it was given. My bug was treating a serializer like a normal form. The tests above are the part I am keeping. The late-night Redis archaeology is not.
If you want a second machine that has not imported your local helpers, a free remote shell is a reasonable place to print the same digest. Use whatever clean box you already trust; the comparison is the method, not the vendor.
Top comments (0)