frozendict Type Complexity¶
A frozendict is an immutable mapping, new in Python 3.15. It is the same hash table as a
dict, keeps insertion order the same way, and answers lookups at the same cost, but
nothing can be added, changed or removed once it is built. It is not a subclass of dict. It is
hashable when every value is, and it computes that hash once and caches it.
n is the number of items in the frozendict, and m is the number of items in the other operand:
the mapping or iterable it is built from, or the right-hand side of |. Key hashing and comparison
are treated as O(1), and so are hashing and comparing a value: a value that is itself a large
container adds its own cost to hash(fd) and ==. As with dict, the O(1) lookups are average
cases: if every key hashes to the same value, each one instead takes O(n).
Complexity Reference¶
frozendict¶
| Operation | Time | Space | Notes |
|---|---|---|---|
frozendict() |
O(1) | O(1) | Empty |
frozendict(mapping), frozendict(iterable), frozendict(**kwargs) |
O(m) | O(m) | One insertion per pair; a repeated key keeps the last value. A dict that had most of its items deleted costs its former size (see dict) |
frozendict(fd) |
O(1) | O(1) | Returns fd itself when both are exact frozendicts |
frozendict.fromkeys(iterable, value=None) |
O(m) | O(m) | Every key maps to the same value object |
fd[key], fd.get(key, default=None) |
O(1) avg, O(n) worst | O(1) | fd[key] raises KeyError for a missing key |
key in fd |
O(1) avg, O(n) worst | O(1) | |
len(fd) |
O(1) | O(1) | The count is stored |
Iterating fd, reversed(fd) |
O(n) | O(1) | Insertion order, or its reverse |
fd.keys(), fd.values(), fd.items() |
O(1) | O(1) | Views; iterating one is O(n) |
fd.copy() |
O(1) | O(1) | Returns fd itself; an instance of a subclass is copied in O(n) |
hash(fd) |
O(n) first call, O(1) after | O(1) | Hashes every value once and caches the result; raises TypeError if a value is unhashable |
fd == other, fd != other |
O(n) | O(1) | Equal to a dict with the same items; order is ignored. O(1) when the lengths differ |
fd \| other |
O(n + m) | O(n + m) | other must be a dict or frozendict; the result is a frozendict, while dict \| fd is a dict |
fd \|= other |
O(n + m) | O(n + m) | Not in place: rebinds the name to fd \| other, and the original object never changes |
dict(fd) |
O(n) | O(n) | A mutable copy |
fd[key] = value, del fd[key] |
O(1) | O(1) | Raise TypeError; there is no update(), pop(), setdefault() or clear() |
Building a frozendict¶
Construction copies every pair once, the same as building a dict. Passing a frozendict that is
already exact costs nothing, because the constructor and copy() both hand back the same
object.
settings = frozendict({'host': 'localhost', 'port': 8080}) # O(m)
assert settings['port'] == 8080 # O(1)
assert frozendict(settings) is settings # O(1) - no copy
assert settings.copy() is settings # O(1) - no copy
flags = frozendict.fromkeys(['debug', 'verbose'], False) # O(m)
assert flags == {'debug': False, 'verbose': False} # O(n), equal to a dict
try:
settings['port'] = 9090
except TypeError as error:
assert 'does not support item assignment' in str(error)
else:
raise AssertionError('a frozendict cannot be changed')
Hashing¶
hash(fd) combines the hash of every key and value, so the first call is O(n). The result is
stored on the object and every later call returns it in O(1), so a frozendict used repeatedly as
a dictionary key or set member pays the O(n) once. The hash ignores insertion order, as equality
does.
a = frozendict(x=1, y=2)
b = frozendict(y=2, x=1)
assert a == b # O(n) - order is ignored
assert hash(a) == hash(b) # O(n) each, the first time
cache = {a: 'computed'} # O(1) - the hash is already cached
assert cache[b] == 'computed' # O(1) hash, then an O(n) equality check
try:
hash(frozendict(tags=['a', 'b'])) # a list value is unhashable
except TypeError as error:
assert 'unhashable' in str(error)
else:
raise AssertionError('an unhashable value must make hash() fail')
Merging With |¶
| copies the left operand and then inserts every item of the right, so it is O(n + m) whichever
side is larger. The result has the left operand's type. A frozendict has no in-place update, so
|= falls back to |: it builds a new frozendict and rebinds the name, and any other reference
still sees the original.
defaults = frozendict(color='red', size=10)
overrides = {'size': 12}
merged = defaults | overrides # O(n + m)
assert type(merged) is frozendict and merged['size'] == 12
assert type(overrides | defaults) is dict # the left operand decides
current = defaults
current |= overrides # O(n + m) - a new object, not an update
assert current is not defaults
assert defaults['size'] == 10
Common Patterns¶
Caching on a Mapping Argument¶
A dict cannot be a cache key, but a frozendict can, and its cached hash keeps repeated lookups
from rehashing it.
from functools import lru_cache
calls = 0
@lru_cache(maxsize=128)
def render(options):
global calls
calls += 1
return ', '.join(f'{k}={v}' for k, v in sorted(options.items())) # O(n log n)
options = frozendict(width=80, indent=2)
assert render(options) == 'indent=2, width=80' # computed once
assert render(options) == 'indent=2, width=80' # O(1) cache hit
assert calls == 1
Performance Best Practices¶
✅ Do:
- Pass a frozendict where an immutable mapping is wanted:
frozendict(fd)andfd.copy()are free, where a defensivedictcopy is O(n) - Use a frozendict as a dictionary key or set member when the key is naturally a mapping; its hash is computed once
❌ Avoid:
- Building a frozendict up with repeated
|=- each step copies everything so far, so k additions cost O(k²); build adictand freeze it once - Hashing a frozendict whose values are unhashable - the
TypeErrorcomes athash()time, not when the frozendict is built
Version Notes¶
- Python 3.15+:
frozendictadded (PEP 814)
Related Types¶
- dict - The mutable mapping with the same lookup costs
- frozenset - The immutable set, which also returns itself from
copy() - types.MappingProxyType - A read-only view of a mapping that can still change underneath, in O(1) without a copy