You try to use a dict as a dictionary key. Python crashes with TypeError: unhashable type: 'dict'. You want to cache a function by its config argument — but a plain dict isn’t hashable so @lru_cache rejects it. You’ve been working around this limitation for years. Python 3.13 finally has the answer built in: frozendict — an immutable, hashable dictionary that slots into every place a regular dict can go, plus a few new ones.
In this tutorial you will learn:
- Why regular dicts can’t be used as keys or stored in sets
- How to create and use frozendict in Python 3.13+
- Three practical use cases: dict keys, sets, and @lru_cache
- How frozendict compares to MappingProxyType and third-party libraries
- When to reach for frozendict vs a regular dict
Page Contents
The Problem: Mutable Dicts Break Hashing
Regular Python dictionaries are powerful but mutable — you can add, remove, or change keys after creation. This mutability creates a hard limitation: dicts cannot be hashed, which means they cannot be used as dictionary keys or stored in sets.
# This raises TypeError: unhashable type: 'dict'
lookup = {}
lookup[{'a': 1, 'b': 2}] = 'value' # TypeError!
# Sets cannot hold dicts either
unique_items = set()
unique_items.add({'x': 1}) # TypeError!
This restriction exists for good reason — if a dict could be a key and you later modified it, its hash value would change and corrupt dictionary internals. But there are many legitimate cases where you need an immutable mapping that is also hashable:
- Caching functions that take config dictionaries as parameters
- Storing unique dict-like configurations as set elements
- Creating composite database-style indexes with dict keys
- Defining configuration presets that cannot accidentally be modified
PEP 814: The Road to frozendict
Python finally has a built-in frozendict — and it is everything the community has been asking for. After years of workarounds and third-party libraries, immutable, hashable dictionaries are now part of Python’s standard library. The journey took years. PEP 814 — “frozendict: Immutable Dictionary Type” — was proposed, discussed extensively, and accepted by the Steering Council in February 2025. Python 3.13 became the first release to include frozendict as a built-in type.
Python already had frozenset since version 2.4, but the analogous frozendict remained elusive. Developers cobbled together workarounds using types.MappingProxyType, custom wrapper classes, or third-party libraries — none of them perfect.
How frozendict Works
Creating a frozendict is identical to creating a regular dict:
# Create a frozendict
fd = frozendict({'name': 'Alice', 'age': 30, 'city': 'Bangalore'})
# Access like a regular dict
print(fd['name']) # Alice
print(fd.get('age')) # 30
print(fd.keys()) # dict_keys(['name', 'age', 'city'])
print(fd.values()) # dict_values(['Alice', 30, 'Bangalore'])
print(fd.items()) # dict_items([('name', 'Alice'), ('age', 30), ...])
# Iteration works exactly like dict
for key, value in fd.items():
print(f"{key}: {value}")
What you cannot do is modify it after creation:
# All of these raise TypeError
fd['country'] = 'India' # TypeError: 'frozendict' object does not support item assignment
fd.update({'country': 'India'}) # TypeError
fd.pop('age') # TypeError
fd.clear() # AttributeError: 'frozendict' object has no attribute 'clear'
The type also supports full type annotations:
from typing import FrozenSet
# Type annotations work naturally
def process_config(config: frozendict) -> str:
return str(config)
# Return type hints
def get_default_config() -> frozendict:
return frozendict({'debug': False, 'version': '1.0'})
And the merge operator (|) for combining frozendicts:
base = frozendict({'debug': False, 'log_level': 'WARNING'})
override = frozendict({'debug': True}) # Only override what you need
# Merge: right side wins on conflicts
config = base | override
# Result: frozendict({'debug': True, 'log_level': 'WARNING'})
frozendict Code Examples
Using frozendict as Dictionary Keys
The most practical use case: storing structured configurations as dictionary keys. This is useful for building lookup tables, caching layers, or indexed data structures. Learn more about Python dicts in our complete guide.
# frozendict can be used as dict keys (not possible with regular dict)
fd = frozendict({'a': 1, 'b': 2})
lookup = {fd: 'value'}
print(lookup[fd]) # Output: value
# Multiple frozendicts as keys in a single dictionary
config1 = frozendict({'env': 'production', 'region': 'us-west'})
config2 = frozendict({'env': 'staging', 'region': 'us-east'})
deployments = {
config1: 'server-prod-1',
config2: 'server-staging-1'
}
print(deployments[config1]) # Output: server-prod-1
Using frozendict in Sets
Storing unique configurations as set elements gives you fast deduplication and membership testing. This pattern shines when you need to track unique parameter combinations — for example, feature flags, experiment configs, or A/B test variants.
# frozendict can be in a set (not possible with regular dict)
unique_configs = set()
unique_configs.add(frozendict({'debug': True, 'max_connections': 10}))
unique_configs.add(frozendict({'debug': False, 'max_connections': 50}))
unique_configs.add(frozendict({'debug': True, 'max_connections': 10})) # Duplicate - ignored
print(len(unique_configs)) # Output: 2
# Efficient membership testing
fd = frozendict({'debug': True, 'max_connections': 10})
print(fd in unique_configs) # Output: True
Using frozendict with @lru_cache
One of the most powerful applications: memoizing functions that take configuration dictionaries as parameters. Read our guide on Python’s None type to understand how caching interacts with null values in your programs.
from functools import lru_cache
@lru_cache
def process_config(config: frozendict):
# Simulating an expensive operation
result = sum(config.values()) * len(config)
return result
# First call - computes and caches
config1 = frozendict({'a': 1, 'b': 2, 'c': 3})
result1 = process_config(config1) # Computed and cached
# Second call with same config - returns cached result instantly
result2 = process_config(config1) # Retrieved from cache (no recomputation)
# Different config - computed separately
config2 = frozendict({'a': 10, 'b': 20})
result3 = process_config(config2) # New computation
print(result1) # Output: 12 (6 * 3)
print(result2) # Output: 12 (cached)
print(result3) # Output: 60 (30 * 2)
print(process_config.cache_info()) # CacheInfo(hits=1, misses=2)
Comparison with Existing Workarounds
types.MappingProxyType
types.MappingProxyType was the closest built-in alternative before frozendict. It wraps a dict and makes it read-only. Python’s official dict documentation covers this in the standard types reference.
import types
regular_dict = {'a': 1, 'b': 2}
proxy = types.MappingProxyType(regular_dict)
# Read operations work
print(proxy['a']) # 1
print(list(proxy.keys())) # ['a', 'b']
# But it is NOT hashable - cannot use as dict key or in set
try:
lookup = {proxy: 'value'} # TypeError: unhashable type: 'MappingProxyType'
except TypeError as e:
print(f"Error: {e}")
# And the regular dict underneath is still mutable!
regular_dict['c'] = 3 # This affects the proxy too
print(proxy) # {'a': 1, 'b': 2, 'c': 3} - proxy changes!
frozendict solves all these limitations: it is truly immutable, hashable, and independent of any underlying mutable dict.
Third-Party Libraries (frozendict package)
The frozendict PyPI package existed for years and inspired the PEP. If you were using it, migration is trivial:
# Old third-party frozendict
from frozendict import frozendict as FrozenDict # Different import name
# Now built-in - just use it directly
fd = frozendict({'key': 'value'}) # No import needed in Python 3.13+
# Migration tip: if you have code using `from frozendict import frozendict`
# just remove the import - frozendict is now built-in
When to Use frozendict vs Regular dict
| Use frozendict when… | Use regular dict when… |
|---|---|
| You need hashability | You need to add/remove items |
| It will be a dict key or in a set | You need in-place modifications |
| Used with @lru_cache | Configuration that changes over time |
| Configuration object (immutable) | Performance is critical (dict is slightly faster) |
| Enum-like or preset patterns | Temporary data structures with short lifecycles |
frozendict has virtually identical read performance to regular dict. The only minor overhead is hash computation for lookups when used as dict keys or in sets — negligible for most use cases. Memory usage is also comparable.
Summary
frozendict is Python 3.13’s answer to one of the most-requested language features. It provides:
- Immutability — cannot be modified after creation
- Hashability — can be used as dict keys and in sets
- Full dict interface — .get(), .keys(), .values(), .items(), iteration
- Merge operator — | combines frozendicts cleanly
- Type annotation support — works naturally with mypy and static analysis
Built on PEP 814, frozendict gives Python developers something frozenset users have had since 2005. If you have ever used MappingProxyType or third-party frozen dict libraries, frozendict is the clean, canonical solution that finally belongs in Python’s standard library.

