Build1 publisher3 min readPublished
frozndict pays for its O(1) hash by hashing every key and value up front
A Rust library gives Python and Node an immutable, insertion-ordered dict whose hash is computed once at construction and then cached. The published numbers were measured at 1,000 entries on Python 3.12.3.
The Engineer · Build desk

What happened
- A developer published frozndict, an immutable, insertion-ordered, O(1)-hashable dictionary written in 100% safe Rust with bindings for both Python and Node.js.
- The structure keeps two slices inside one Arc-shared struct: entries in insertion order, and a second lookup table sorted by hash for binary search, plus lazily cached view lists.
- Stated complexities are O(n) ordered iteration, O(log n + k) lookup, O(n log n) construction from a single sort of the lookup table, and O(1) copy and clone.
- Every mutating method, including update, pop, popitem and setdefault, is implemented as a gate that raises TypeError from the Rust layer.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- capability A mapping can now be passed straight to an lru_cache-decorated function instead of being canonicalised into a sorted tuple of items, and two dicts with the same pairs in different order hit the same cache entry.
- cost Adopters pay a sort and a hash of every key and value at build time, so the library only comes out ahead where keys are constructed once and then hashed, copied or compared many times.
- constraint Any dict holding an unhashable value, such as a list, cannot be converted. That excludes the config-and-payload dicts that most often end up as cache keys.
- decision Code that detects an immutable mapping by catching AttributeError will not catch this one, so a migration has to touch exception handling and not just the constructor.
Construction is where the hashing and the sorting happen. Building a FrozenDict hashes every key and every value, XOR-mixes each pair as `key_hash * MIX_KEY ^ value_hash * MIX_VAL` into one isize, and sorts a separate slice of hash-and-index pairs so later lookups can binary search it [9][7]. After that, `hash()` returns the cached integer and `copy()` hands back the same `Arc` in 63 ns [5][6]. The hashing and the sorting shift to the line where the key is built, and they run whether or not anything ever hashes the result [5].
Every value therefore has to be hashable at build time, so a mapping with a list value is not a FrozenDict [4].
Splitting insertion order from hash order into two slices is the good part of this design [7]. Lookup is O(log n + k): binary search to the hash bucket, then a linear scan over k collisions [8]. At the benchmark size of 1,000 entries that is about ten hash comparisons, since log2(1000) is 9.97 [1]. At a million entries it is about twenty [2]. A hash table does not grow its probe count that way. Each doubling of the dict adds a probe.
The post quotes a 40 nanosecond lookup path against Python function call overhead of 60 to 100 ns [13]. Those two figures measure different things. For the comparison to support replacing `dict`, the baseline would have to be dict's own subscript time, and what is quoted is the cost of calling a Python function [13].
Bench conditions are stated: timeit, Python 3.12.3, x86-64 Linux, minimum of 7 runs of 2,000 iterations, 1,000 entries [10]. Minimum-of-seven reports the best case. Views are cached in a `OnceLock` and shared, so within one 2,000-iteration batch the first call builds the PyList and the remaining 1,999 read it back, which is 99.95 percent of the measured calls [7][3].
The author wrote that the pure Rust functions run "approximately 1,000,000x faster than any Python-level re-implementation of the same logic would be" [12]. The baseline in that sentence is a program nobody wrote. The measured claims are narrower and more useful: wins on iteration, `copy()`, equality and clone, and a near tie with `immutables.Map` on `hash()` [11].
The 15-year framing belongs to the post, and the post says that gap already closed. "Nobody solved it properly for dicts for 15+ years, until frozendict (the C extension) came along", the author wrote [3]. What followed was a look at frozendict's construction time [17]. So the offer here is speed against two existing Python packages, with Node bindings shipped alongside [11][1].
Equality ignores insertion order and equal contents share a hash, while the views iterate in the order you inserted [9][16]. Both hold at once, so `a == b` and `hash(a) == hash(b)` while `list(a) != list(b)` [5]. A cache keyed on FrozenDict returns the value stored under whichever ordering arrived first, and code that iterates the key it got back sees that ordering. Early versions compared entries positionally and answered False for the same pairs in a different order; the author says he found it while writing tests at midnight [14].
What to watch
- A construction benchmark against the frozendict C extension at several sizes. That is the comparison the O(n log n) sort has to win.
- Node-side numbers against Immutable.js or a plain Map, since the comparisons named so far are Python packages.
- Lookup timings at 100,000 and 1,000,000 entries, where binary search depth roughly doubles against the published 1,000-entry case.