DEV Community

Taylor Wang
Taylor Wang

Posted on

I Chased a Cache Miss for 48 Hours. json.dumps Was Not Canonical.

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}))"
Enter fullscreen mode Exit fullscreen mode

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 id and the other kept request order.
  • None versus a missing key produced different JSON, even though my mental model said “optional.”
  • True and 1 are 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}"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Run it like this:

python -m pytest test_canonical_cache_key.py -q
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Log the raw dumps text from both sides, not a pretty dict.
  2. Confirm both sides import the same canonicalizer module, not a cousin helper.
  3. Check list order, None versus missing, and bool versus int.
  4. Run the same fixture in a process that did not import application defaults.
  5. 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)