Python dictionaries are one of the most commonly used data structures in application code.
They are also mutable.
That is useful when data needs to change, but it can become a problem when a dictionary represents configuration, application metadata, fixed mappings, or shared state that should never be modified.
Python 3.15 introduces a built-in frozendict type for this purpose. Unlike a normal dict, a frozendict cannot be modified after it is created. It is also hashable when all of its keys and values are hashable.
This gives Python developers a standard immutable mapping without needing a third-party package or a custom wrapper.
But that does not mean every dictionary should become a frozendict.
The important question is: when does an immutable dictionary actually make sense?
What Is frozendict?
A normal dictionary is mutable:
config = {
"host": "localhost",
"port": 5432
}
config["port"] = 5433
print(config)
The dictionary can be changed at any time.
A frozendict works differently:
config = frozendict(
host="localhost",
port=5432
)
print(config)
Trying to modify it raises an error:
config["port"] = 5433
The operation fails because frozendict does not support item assignment.
You can also create one from an existing dictionary:
settings = {
"host": "localhost",
"port": 5432,
"debug": False
}
frozen_settings = frozendict(settings)
print(frozen_settings)
The resulting object is immutable.
Why Was frozendict Added?
Python already had ways to create read-only views of dictionaries, such as types.MappingProxyType.
However, a mapping proxy and a frozen dictionary have different semantics.
A mapping proxy is a read-only view of another dictionary. If the original dictionary changes, the proxy can reflect those changes.
A frozendict is an immutable dictionary object in its own right.
Python 3.15 documents frozendict as a built-in mapping type. It is not a subclass of dict; it inherits directly from object.
That distinction matters when writing APIs and type checks.
Creating a frozendict
There are several straightforward ways to create one.
Using Keyword Arguments
server = frozendict(
host="api.example.com",
port=443,
secure=True
)
From a Dictionary
server_config = {
"host": "api.example.com",
"port": 443,
"secure": True
}
server = frozendict(server_config)
From Key-Value Pairs
server = frozendict([
("host", "api.example.com"),
("port", 443),
("secure", True)
])
The constructor accepts a mapping or an iterable of key-value pairs, along with keyword arguments.
The Main Difference: Mutation
With dict, these operations are normal:
settings = {
"timeout": 30,
"retries": 3
}
settings["timeout"] = 60
settings["debug"] = True
settings.pop("retries")
With frozendict, mutation operations are not available.
For example:
settings = frozendict(
timeout=30,
retries=3
)
settings["timeout"] = 60
This raises:
TypeError: 'frozendict' object does not support item assignment
Several mutating dictionary methods are intentionally absent, including:
__setitem__()
__delitem__()
clear()
pop()
popitem()
setdefault()
update()
The Python documentation explicitly lists these differences from dict.
This makes the intended contract clear:
Once created, this mapping should not change.
frozendict Can Be Hashable
One of the most useful differences is that a frozendict can be hashed when all of its keys and values are hashable.
For example:
database_config = frozendict(
host="localhost",
port=5432
)
print(hash(database_config))
This makes it possible to use a frozendict as a member of a set or as a key in another mapping.
For example:
cache = {}
key = frozendict(
region="us-east",
tier="premium"
)
cache[key] = "cached-result"
This is not possible with an ordinary dictionary because a normal dict is mutable and therefore unhashable.
Hashability Has an Important Condition
The entire frozendict must contain hashable keys and values.
This works:
settings = frozendict(
timeout=30,
retries=3,
region="us-east"
)
print(hash(settings))
But consider:
settings = frozendict(
options=["fast", "safe"]
)
The value is a list.
Lists are mutable and unhashable.
Therefore, the frozendict cannot be hashed.
This distinction is important:
Immutable mapping
≠
Every contained object is immutable
A frozendict prevents changes to its own key-value structure. It does not automatically make mutable objects stored inside it immutable.
A Mutable Value Can Still Change
Consider:
settings = frozendict(
features=["search", "reports"]
)
You cannot replace the features entry:
settings["features"] = []
But the list itself is still mutable:
settings["features"].append("analytics")
The mapping has not changed its key or value reference, but the object referenced by the value has changed.
This is an important production consideration.
If you need deep immutability, the values themselves must also be immutable.
For example:
settings = frozendict(
features=("search", "reports")
)
Now the value is a tuple rather than a list.
If the tuple contains only hashable objects, the complete frozendict can also be hashable.
Equality Does Not Depend on Insertion Order
A frozendict preserves insertion order when iterated, similar to a normal dictionary.
However, equality does not depend on insertion order.
For example:
first = frozendict(
language="Python",
version=3.15
)
second = frozendict(
version=3.15,
language="Python"
)
print(first == second)
The result is:
True
The objects contain the same key-value pairs even though they were created in a different order.
This makes sense for a mapping because the relationship between a key and its value is more important than the order in which those pairs were added.
When Should You Use frozendict?
frozendict is most useful when the mapping represents data that should remain fixed after construction.
Good examples include:
Application constants
Immutable configuration
Metadata
Lookup tables
Cache keys
Function configuration
Shared application state
Plugin metadata
Serialization-oriented structures
Fixed mappings passed between components
For example:
HTTP_STATUS = frozendict(
ok=200,
created=201,
bad_request=400,
unauthorized=401,
not_found=404,
server_error=500
)
The mapping communicates an important design decision:
These values are not supposed to be changed at runtime.
Using frozendict for Configuration
Configuration is one of the easiest places to understand the value of immutability.
Consider:
DATABASE_CONFIG = {
"host": "db.internal",
"port": 5432,
"pool_size": 20
}
Any part of the application with access to this object can modify it:
DATABASE_CONFIG["pool_size"] = 100
That can create difficult-to-debug behavior.
A frozen configuration makes the contract clearer:
DATABASE_CONFIG = frozendict(
host="db.internal",
port=5432,
pool_size=20
)
Now accidental mutation is rejected immediately.
If another component needs a different configuration, create a new object rather than modifying the existing one.
Updating a frozendict Means Creating Another One
A common misconception is that immutable objects cannot be changed at all.
The more accurate way to think about them is:
You cannot modify the existing object, but you can create a new object with different values.
For example:
original = frozendict(
timeout=30,
retries=3
)
updated = frozendict(
**original,
timeout=60
)
Now:
print(original)
print(updated)
The original remains unchanged.
Conceptually:
original
|
| create new version
v
updated
This pattern can be useful when configuration objects are shared across multiple components.
Using frozendict as a Cache Key
Suppose an application caches results based on several parameters:
query_options = {
"page": 1,
"page_size": 50,
"sort": "date"
}
A normal dictionary cannot be used as a dictionary key:
cache[query_options] = result
This raises an error because dictionaries are unhashable.
With frozendict, a hashable configuration can become part of the cache key:
query_options = frozendict(
page=1,
page_size=50,
sort="date"
)
cache[query_options] = result
This can be useful for memoization and caching systems where the complete set of parameters represents a logical identity.
However, make sure all nested values are hashable before relying on this behavior.
Using frozendict in Application Metadata
Consider a plugin system:
PLUGIN_METADATA = frozendict(
name="reporting",
version="2.4",
capabilities=("pdf", "csv", "json")
)
A plugin can read the metadata:
print(PLUGIN_METADATA["name"])
print(PLUGIN_METADATA["capabilities"])
But it cannot modify the registration metadata accidentally.
This can be useful for systems where metadata is created during startup and then consumed by many components.
frozendict vs dict
The choice between dict and frozendict should be based on whether mutation is part of the design.
Feature |
|
|
|---|---|---|
Mutable | Yes | No |
Built into Python | Yes | Yes, in Python 3.15 |
Supports | Yes | No |
Supports item assignment | Yes | No |
Hashable | No | Yes, when contents are hashable |
Good for changing data | Yes | No |
Good for fixed mappings | Sometimes | Yes |
Subclass of | Yes | No |
Preserves insertion order | Yes | Yes |
Equality depends on order | No | No |
The biggest difference is not performance.
It is the data contract.
frozendict vs MappingProxyType
Python developers may already know about MappingProxyType.
Consider:
from types import MappingProxyType
config = {
"timeout": 30
}
readonly_config = MappingProxyType(config)
The proxy prevents direct mutation through readonly_config:
readonly_config["timeout"] = 60
But the original dictionary can still change:
config["timeout"] = 60
print(readonly_config["timeout"])
The proxy reflects the underlying dictionary.
A frozendict is different:
config = frozendict(
timeout=30
)
The object itself is immutable.
This makes the two types useful for different purposes.
Requirement | Suitable Choice |
|---|---|
Need a mutable dictionary |
|
Need a read-only view of an existing dictionary |
|
Need an immutable dictionary object |
|
Need a hashable mapping |
|
frozendict Is Not a Drop-In dict Subclass
This is an important compatibility detail.
Because frozendict is not a subclass of dict, code such as:
isinstance(value, dict)
will not identify a frozendict as a dictionary. Python's documentation specifically notes this distinction.
If an API should accept different mapping implementations, prefer the mapping abstraction where appropriate:
from collections.abc import Mapping
def process_config(config: Mapping):
...
This is often more flexible than requiring:
isinstance(config, dict)
This matters when gradually introducing frozendict into an existing codebase.
Standard Library Support
Python 3.15 also updates several standard-library modules to accept frozendict, including:
copy
decimal
json
marshal
plistlib
pickle
pprint
xml.etree.ElementTree
Python's 3.15 documentation lists these changes as part of the new built-in type.
This is useful because frozendict is not an isolated feature that only works with a small part of the standard library.
For example:
import json
config = frozendict(
host="localhost",
port=5432
)
print(json.dumps(config))
The broader standard-library integration makes the type easier to introduce into normal Python applications.
Dataclasses and frozendict
Python 3.15 also uses frozendict in parts of the standard library.
For example, dataclasses now uses an empty frozendict when field metadata is None, instead of an empty MappingProxyType.
This is a good example of where an immutable mapping fits naturally: metadata describes a structure and generally should not be changed after the field definition is created.
Application developers do not need to change their existing dataclass code just because of this change, but it demonstrates where immutable mappings are useful inside Python itself.
When You Should Not Use frozendict
There are many situations where a normal dictionary is still the right choice.
For example:
user = {
"name": "Alice",
"login_count": 10
}
user["login_count"] += 1
This data is expected to change.
Using frozendict here would make the code unnecessarily complicated.
The same applies to:
Request payloads being assembled incrementally
Temporary transformation dictionaries
Mutable application state
Aggregation results
Dictionaries used as working buffers
Data structures frequently updated in loops
For example, this is perfectly reasonable:
counts = {}
for item in items:
counts[item] = counts.get(item, 0) + 1
Turning every intermediate dictionary into an immutable object would make this pattern worse, not better.
Common Mistakes
Replacing Every dict With frozendict
Immutability is useful when mutation is undesirable.
It is not automatically better for every data structure.
Assuming It Provides Deep Immutability
This is not true:
data = frozendict(
values=[1, 2, 3]
)
The list can still be changed.
If nested objects must also be immutable, design those values accordingly.
Checking Only for dict
Existing code may contain:
if isinstance(config, dict):
...
That will not match frozendict.
For APIs that should accept multiple mapping types, use an appropriate mapping abstraction.
Assuming Hashability Is Automatic
A frozendict is hashable only when its keys and values are hashable.
Nested mutable values can prevent hashing.
Expecting update()
This will not work:
config.update({
"timeout": 60
})
Create a new frozendict instead.
Best Practices
When introducing frozendict into a Python application:
Use it when the mapping represents data that should not change.
Keep ordinary
dictfor genuinely mutable application state.Use hashable values when you need the
frozendictto act as a cache key.Do not assume
frozendictprovides deep immutability.Prefer
Mappingin APIs that should accept multiple mapping implementations.Check existing
isinstance(..., dict)logic before introducing it into a mature codebase.Create a new
frozendictwhen configuration needs to change.Avoid converting temporary working dictionaries unnecessarily.
Test serialization and integration points when migrating existing configuration objects.
Treat immutability as part of the design contract, not as a performance trick.
A Practical Configuration Example
Here is a simple pattern for an application:
DEFAULT_CONFIG = frozendict(
host="localhost",
port=5432,
timeout=30,
retries=3
)
def connect(config=DEFAULT_CONFIG):
print(f"Connecting to {config['host']}:{config['port']}")
print(f"Timeout: {config['timeout']} seconds")
A caller can use the defaults:
connect()
Or provide a different configuration:
custom_config = frozendict(
host="db.internal",
port=5432,
timeout=60,
retries=5
)
connect(custom_config)
The important part is that neither configuration can be accidentally modified by the function.
That makes the ownership of configuration much clearer.
Advantages and Disadvantages
Advantages
Built into Python 3.15.
Prevents accidental mutation.
Can be hashable when contents are hashable.
Useful as a cache key.
Works naturally for fixed configuration and metadata.
Preserves insertion order.
Integrates with several standard-library modules.
Disadvantages
Cannot be modified in place.
Existing code expecting a
dictmay need adjustment.It is not a
dictsubclass.Nested mutable values can still change.
Creating new objects is required when values need to change.
It should not replace dictionaries used for normal mutable application state.
Summary
Python 3.15's frozendict provides a built-in immutable mapping for situations where a normal dictionary is too easy to modify accidentally.
It is particularly useful for fixed configuration, metadata, lookup tables, shared read-only state, and hashable cache keys. A frozendict is hashable when all of its keys and values are hashable, but the object does not provide deep immutability for mutable objects stored inside it.
The most important thing is not to treat frozendict as a replacement for dict.
Use dict when the data is supposed to change.
Use frozendict when the data represents a fixed mapping and preventing accidental mutation is part of the design.
That simple distinction makes the new type much easier to use correctly in real Python applications.

Join the conversation! Your thoughts help the community grow.