Python developers use dictionaries everywhere.

Configuration, API responses, application state, lookup tables, function arguments, caches, and internal data structures often rely on dict. It is one of the most useful types in Python because it is flexible and easy to modify.

But that flexibility is not always what you want.

Sometimes a dictionary should be created once and then treated as fixed data. You may want to pass it between functions without worrying that one part of the application will accidentally change it. You may also want to use a dictionary as a key in another mapping or store it in a set.

Before Python 3.15, Python did not have a built-in immutable dictionary type that behaved like a normal mapping.

Python 3.15 changes that with the new built-in frozendict.

config = frozendict(
    host="localhost",
    port=5432,
    debug=False
)

Once created, its entries cannot be changed.

That sounds simple, but it solves several practical problems. The important question is not whether frozendict is better than dict. It is:

When should you use an immutable mapping instead of a mutable one?

What Is frozendict?

frozendict is a new built-in immutable mapping type introduced in Python 3.15.

For example:

settings = frozendict(
    environment="production",
    timeout=30,
    retries=3
)

You can read values normally:

print(settings["environment"])
print(settings["timeout"])

But you cannot modify the mapping:

settings["timeout"] = 60

This raises an error because frozendict does not support item assignment.

You also cannot use dictionary mutation methods such as:

settings.update(...)
settings.pop(...)
settings.clear()

The main idea is straightforward:

Create the mapping once, then treat its contents as fixed.

Python 3.15 adds frozendict directly to the built-in namespace, so no third-party package is required.

Why Was frozendict Needed?

Python already had a few ways to prevent dictionary modification, so it is reasonable to ask why another type was necessary.

One existing option is MappingProxyType.

For example:

from types import MappingProxyType

settings = {
    "timeout": 30,
    "retries": 3
}

readonly_settings = MappingProxyType(settings)

This gives you a read-only view of the original dictionary.

But there is an important difference.

The underlying dictionary can still change:

settings["timeout"] = 60

print(readonly_settings["timeout"])

The read-only view now sees the changed value.

frozendict works differently.

settings = frozendict(
    timeout=30,
    retries=3
)

The frozendict itself contains its own immutable mapping.

This makes it useful when you want the data structure itself to represent a fixed value rather than simply expose a read-only view.

dict vs frozendict

The simplest difference is mutability.

Feature

dict

frozendict

Mutable

Yes

No

Item assignment

Yes

No

update()

Yes

No

pop()

Yes

No

clear()

Yes

No

Hashable

No

Yes, when keys and values are hashable

Can be used as a mapping key

No

Yes, when hashable

Preserves insertion order

Yes

Yes

Subclass of dict

Yes

No

There is one detail worth remembering: frozendict is not a subclass of dict. It inherits directly from object.

That can matter when existing code performs strict type checks.

Basic frozendict Usage

The constructor looks similar to dict.

You can use keyword arguments:

user = frozendict(
    name="Baibhav",
    role="Developer"
)

You can pass another mapping:

user_data = {
    "name": "Baibhav",
    "role": "Developer"
}

user = frozendict(user_data)

You can also create one from an iterable of key-value pairs:

user = frozendict([
    ("name", "Baibhav"),
    ("role", "Developer")
])

Reading values works in the familiar way:

print(user["name"])
print(user.get("role"))

You can also iterate over it:

for key, value in user.items():
    print(key, value)

The API is intentionally close to dict, but mutation operations are removed.

The Main Benefit: Preventing Accidental Changes

Consider a configuration object:

config = {
    "timeout": 30,
    "retries": 3,
    "environment": "production"
}

You pass it into several functions:

load_database(config)
start_server(config)
configure_logging(config)

Any of those functions can change it:

def load_database(config):
    config["timeout"] = 120

Now the caller has a different configuration than it originally created.

That kind of bug can be surprisingly difficult to find in a large codebase.

With frozendict:

config = frozendict(
    timeout=30,
    retries=3,
    environment="production"
)

A function cannot silently modify the mapping:

def load_database(config):
    config["timeout"] = 120

The attempted mutation fails immediately.

That is useful because the problem is caught at the point where the incorrect operation happens.

Configuration Is a Natural Use Case

Configuration data is one of the easiest places to understand the value of immutability.

For example:

DATABASE_CONFIG = frozendict(
    host="db.internal",
    port=5432,
    pool_size=10
)

If the configuration is intended to remain fixed during the lifetime of the process, using a mutable dictionary communicates the wrong idea.

With frozendict, the type itself tells other developers:

This mapping is not supposed to change.

That is often better than relying on a comment such as:

# Do not modify this dictionary

Comments depend on developers following the rule.

An immutable type enforces the rule.

frozendict Is Only Shallowly Immutable

This is one of the most important details.

Consider:

config = frozendict(
    name="app",
    features=["logging", "metrics"]
)

You cannot replace the features entry:

config["features"] = []

But the list stored inside the frozendict is still mutable:

config["features"].append("tracing")

The mapping itself has not changed its key-to-value association. The value object was changed.

So frozendict provides shallow immutability.

If you need the entire object graph to be immutable, the values must also use immutable structures.

For example:

config = frozendict(
    name="app",
    features=("logging", "metrics")
)

Now the tuple cannot be modified either.

This distinction is important when using frozendict for shared application state.

Hashability Is One of the Most Useful Features

A normal dictionary cannot be hashed:

data = {
    "name": "Baibhav"
}

hash(data)

This raises a TypeError.

That is because a mutable dictionary cannot safely be used as a hash-based key.

A frozendict can be hashable when all its keys and values are themselves hashable.

For example:

data = frozendict(
    name="Baibhav",
    role="Developer"
)

print(hash(data))

This makes another pattern possible:

cache = {}

key = frozendict(
    language="python",
    version=3.15
)

cache[key] = "processed"

The frozendict can act as the key because its contents cannot be changed after creation.

Python's documentation specifically defines hashability in terms of all keys and values being hashable.

Using frozendict as a Cache Key

This can be useful for memoization or application-level caching.

Suppose a function depends on several configuration options:

options = frozendict(
    language="en",
    currency="USD",
    precision=2
)

You can use the complete configuration as part of a cache key:

cache = {}

if options in cache:
    result = cache[options]
else:
    result = calculate(options)
    cache[options] = result

With a mutable dictionary, this pattern would not work directly because the dictionary is not hashable.

The immutable mapping makes the intent much clearer.

However, remember that all values need to be hashable.

This will not work:

options = frozendict(
    language="en",
    features=["a", "b"]
)

because the list is not hashable.

Equality Does Not Depend on Insertion Order

frozendict preserves insertion order, just like modern Python dictionaries.

But equality is based on the mapping's contents rather than 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 order in which the entries were inserted does not change mapping equality.

The same principle applies when comparing mappings by their contents.

frozendict and the Union Operator

Python dictionaries support the | operator for creating a merged dictionary:

first = {"a": 1}
second = {"b": 2}

result = first | second

frozendict supports mapping union as well.

For example:

first = frozendict(a=1)
second = frozendict(b=2)

result = first | second

The important part is that the original objects remain unchanged.

You get a new mapping instead of modifying the existing one.

There is also an important difference with |=.

With a normal dictionary:

data |= other

the dictionary is updated in place.

With frozendict:

data |= other

a new frozen dictionary is produced rather than modifying the existing object.

That fits the immutable design.

Passing frozendict Between Functions

Another useful pattern is using frozendict to communicate that a function only needs read access.

For example:

def create_connection(config):
    host = config["host"]
    port = config["port"]

    return connect(host, port)

The function does not need to know whether the mapping is a normal dictionary or an immutable one.

This is where abstract mapping types can be useful.

Instead of checking for exactly dict:

isinstance(config, dict)

you can often use:

from collections.abc import Mapping

isinstance(config, Mapping)

This accepts different mapping implementations rather than requiring a specific concrete type.

Python's 3.15 documentation specifically recommends considering Mapping when code should accept dict, frozendict, and other mapping implementations.

Be Careful With isinstance(value, dict)

Because frozendict is not a dict subclass, existing code like this will return False:

data = frozendict(name="Baibhav")

print(isinstance(data, dict))

This is an important compatibility consideration.

Suppose an existing function contains:

def process(data):
    if isinstance(data, dict):
        return handle_dictionary(data)

    raise TypeError("Expected dict")

Passing a frozendict will fail that check.

If the function really needs any mapping, change the type check:

from collections.abc import Mapping

def process(data):
    if isinstance(data, Mapping):
        return handle_dictionary(data)

    raise TypeError("Expected mapping")

This is usually a better design when mutation is not required.

When dict Is Still the Better Choice

frozendict is not a replacement for dict.

If you need to build a mapping incrementally, dict is the natural choice.

For example:

result = {}

for item in items:
    result[item.name] = item.value

Trying to use an immutable structure for every intermediate operation would make the code unnecessarily awkward.

A common pattern is:

result = {}

for item in items:
    result[item.name] = item.value

result = frozendict(result)

Build the data using a mutable dictionary, then freeze it when it becomes final.

This is often a much more practical approach.

frozendict for Default Configuration

Immutable defaults can also make APIs easier to reason about.

For example:

DEFAULT_OPTIONS = frozendict(
    timeout=30,
    retries=3,
    debug=False
)

A function can read from those defaults without worrying that another part of the program will update the global object.

If a caller needs different values, create a new mapping:

options = DEFAULT_OPTIONS | frozendict(
    timeout=60
)

The original defaults remain unchanged.

This gives a clean separation between:

  • Shared defaults

  • Per-call overrides

  • Final configuration

frozendict and Thread Safety

It is tempting to say that an immutable dictionary automatically makes shared state thread-safe.

That would be too broad.

The mapping itself cannot be structurally modified, which removes one class of mutation problems.

But the objects stored inside it can still be mutable.

For example:

state = frozendict(
    users=[]
)

The list can still be changed:

state["users"].append("Baibhav")

So immutability of the mapping does not automatically make the entire object graph safe for concurrent access.

If shared state needs stronger guarantees, use immutable values as well and consider the concurrency model of the application.

frozendict vs MappingProxyType

These two types are easy to confuse.

Feature

MappingProxyType

frozendict

Read-only access

Yes

Yes

Own immutable mapping

No, it is a view

Yes

Underlying source can change

Yes

No

Hashable

No

Yes, when contents are hashable

Built-in type

No

Yes

Can be used as mapping key

No

Yes, when hashable

dict subclass

No

No

Use MappingProxyType when you specifically want a read-only view of an existing dictionary.

Use frozendict when you want the mapping itself to represent immutable data.

That distinction is probably more useful than simply thinking of one as the "new" version of the other.

Standard Library Support

Python 3.15 also updates several standard library modules to understand frozendict.

The current documentation lists support in modules including:

  • copy

  • decimal

  • json

  • marshal

  • plistlib

  • pickle

  • pprint

  • xml.etree.ElementTree

eval() and exec() also accept frozendict for globals, while other APIs have been updated to recognize it where appropriate.

This matters because frozendict is not just a small utility type living outside the standard library. It is being integrated into Python's own APIs.

A Practical Configuration Example

Imagine a web service with fixed application settings:

DEFAULT_CONFIG = frozendict(
    host="localhost",
    port=8000,
    workers=4,
    debug=False
)

A request handler can safely read it:

def get_server_port():
    return DEFAULT_CONFIG["port"]

If someone accidentally writes:

DEFAULT_CONFIG["port"] = 9000

the application immediately raises an error.

If you need a modified configuration, create another object:

development_config = DEFAULT_CONFIG | frozendict(
    debug=True
)

Now:

print(DEFAULT_CONFIG["debug"])
print(development_config["debug"])

produces:

False
True

The original configuration remains unchanged.

This is a simple example, but the same idea works for feature flags, parser options, request policies, application defaults, and other data that should not be changed after initialization.

Common Mistakes

Assuming frozendict Is a dict

It is not.

This can break code that performs strict dict checks.

Prefer Mapping when the code only needs mapping behavior.

Assuming It Makes Nested Data Immutable

It does not.

A list stored inside a frozendict is still mutable.

Using It Everywhere

Mutable dictionaries are still the right choice for data that needs to change frequently.

Expecting Automatic Performance Improvements

The main reason to use frozendict is its immutability semantics, not because it is guaranteed to be faster than dict.

The Python Steering Council explicitly emphasized this distinction when accepting PEP 814.

Forgetting Hashability Rules

A frozendict is hashable only when its keys and values are hashable.

For example:

frozendict(
    name="Baibhav",
    tags=("python", "backend")
)

can be hashable.

But:

frozendict(
    name="Baibhav",
    tags=["python", "backend"]
)

cannot be hashed because the list is mutable.

When Should You Choose frozendict?

A useful rule is:

Use dict when the data is expected to change. Use frozendict when the data represents a completed value that should not change.

Good candidates include:

  • Application defaults

  • Configuration snapshots

  • Immutable options

  • Cache keys

  • Lookup data

  • Fixed metadata

  • Shared read-only state

  • Function arguments that should not be modified

  • Data used as part of a hash-based key

A normal dictionary remains better for:

  • Building results incrementally

  • Mutable application state

  • Counters

  • Request processing

  • Accumulating data

  • Frequently changing collections

The decision should come from the behavior you need, not simply from the fact that Python 3.15 introduced a new type.

A Simple Migration Strategy

If you already have a large Python application, do not replace every dictionary with frozendict.

Start with clearly immutable data.

For example, find code like:

DEFAULT_HEADERS = {
    "Content-Type": "application/json",
    "Cache-Control": "no-cache"
}

If those values are never supposed to change, consider:

DEFAULT_HEADERS = frozendict(
    {
        "Content-Type": "application/json",
        "Cache-Control": "no-cache"
    }
)

Then run the application's tests.

Pay particular attention to code that:

  • Calls update()

  • Assigns dictionary keys

  • Calls pop()

  • Expects an actual dict

  • Serializes the value

  • Passes the object to third-party libraries

Change one area at a time.

This makes it much easier to find compatibility problems.

Final Thoughts

frozendict is a relatively small addition to Python 3.15, but it fills a useful gap in the language.

For years, Python developers have had mutable dict objects and various read-only workarounds, but there was no built-in immutable dictionary type with its own value semantics.

Now there is one.

The most useful way to think about frozendict is not as a faster dictionary or a replacement for every dict.

Think of it as a way to say:

This mapping is a value. Once created, it should not change.

That can make configuration, caching, shared state, and function interfaces easier to reason about.

For new code, start small. Use frozendict where immutability is actually part of the design, keep dict where mutation is useful, and use Mapping in APIs that only need read-only mapping behavior.

Summary

Python 3.15 adds frozendict, a built-in immutable dictionary-like type.

It works much like a normal dictionary for reading data, but it does not allow entries to be added, changed, or removed. When all of its keys and values are hashable, a frozendict is also hashable, which means it can be used as a key in another mapping or stored in a set.

It is a good fit for fixed configuration, cache keys, default options, and other data that should not change after creation.

It is not a replacement for dict, and it does not make nested objects immutable. If your data needs to change, keep using dict. If the data represents a fixed value, frozendict gives Python a much cleaner way to express that intent.