Python 3.15 frozendict: An Immutable Mapping Can Still Hold a Mutable List
Learn what Python 3.15 frozendict freezes, why nested lists can still change, and how to design configuration records and cache keys with explicit contracts.
A configuration object is labeled immutable. One function receives it, appends a value to a nested list, and changes what another function sees. Nothing was assigned to the outer mapping, yet the supposedly fixed configuration has changed in a way that matters.
Python 3.15 introduces the built-in frozendict, making this a timely distinction to learn. The released type documentation describes its mapping API and conditional hashability. It prevents changing the outer key-to-value bindings; that is a different contract from recursively freezing every reachable object.
This guide builds an original configuration example and a review worksheet. The version-specific excerpts require Python 3.15.
First identify what must stay fixed
For a configuration used throughout one report, you may need the chosen currency, the allowed categories and the validation version to remain stable. A fixed outer mapping ensures that callers cannot replace those bindings through that object. It does not establish that the original choices were valid.
Write down whether you want protection against accidental reassignment, a snapshot independent of an original container, or a fully immutable value structure. Those are related requirements, but they lead to different implementations.
| Requirement | Question to ask | Example risk |
|---|---|---|
| Fixed bindings | Can a caller replace the value at a key? | Currency changes halfway through a run |
| Independent containers | Can another reference alter a nested collection? | Shared list gains a new category |
| Valid domain | Are the values meaningful and allowed? | Unknown currency code |
| Stable cache meaning | Does equality match the computation's inputs? | Different task versions reuse a key |
An immutable object can preserve an invalid value perfectly. Validate data before treating the object as a trustworthy record.
Trace a shallow freeze
PEP 814 specifies that construction from a dictionary copies its items shallowly. It also shows that values may be non-hashable and that nested mutable values can still change. The following small example makes the reference relationship visible:
# Requires Python 3.15.
tags = ["paid"]
settings = frozendict({"tags": tags, "currency": "USD"})
tags.append("review")
assert settings["tags"] == ["paid", "review"]
The list was not replaced inside settings. The same list object changed. A second caller that holds only the frozen mapping can also access that list and call its methods. A frozen outer boundary is useful, but its usefulness depends on the contract you intend to enforce.
Try drawing one box for the mapping and another for the list, with a reference between them. The operation changes the list box. It never needs to edit the mapping box.
Choose nested values deliberately
If order matters and the category sequence should be fixed, a tuple of validated strings may be appropriate. If order does not matter and duplicates should disappear, a frozenset may match the domain better. These choices change meaning; do not apply them mechanically just to make a hash operation succeed.
# Requires Python 3.15.
settings = frozendict({
"tags": ("paid", "review"),
"currency": "USD",
"schema_version": 2,
})
assert settings["tags"][0] == "paid"
A tuple can itself contain a list. Therefore replacing a list with a tuple at one level does not prove recursive immutability either. Inspect the permitted value types and reject unsupported structures at the boundary.
For a small configuration, an explicit constructor that accepts known fields is often easier to reason about than a universal “freeze anything” function. A general recursive conversion must define behavior for cycles, custom objects, mappings with unusual keys and values whose ordering matters.
Separate hashability from correctness
The built-in documentation states that a frozendict is hashable when its keys and values are hashable. A mapping containing a list therefore cannot simply become a dictionary key. A mapping of suitable scalar and immutable values can.
A successful hash() call only establishes compatibility with hashing under the objects' contracts. It does not establish that your cache key includes every input that affects the result. If the computation also depends on the source file version, timezone or algorithm version, those dependencies need representation or another explicit invalidation policy.
Never use Python's process-local hash value as a durable content identifier. For persisted identity, define a stable serialization and appropriate digest separately. The representation must preserve the distinctions relevant to your application.
Make updates produce a new record
A fixed configuration often needs an intentional revision. Build a new object for the next configuration and retain the old object for the current operation. That makes the version boundary easier to explain than modifying shared state while work is in progress.
# Requires Python 3.15.
base = frozendict({"currency": "USD", "schema_version": 2})
next_settings = base | {"schema_version": 3}
assert base["schema_version"] == 2
assert next_settings["schema_version"] == 3
PEP 814 describes union behavior in which a shared key takes the right-hand value. The application still needs to decide whether that revision is permitted. A convenient merge operation does not replace schema validation or migration rules.
Keep the original object reference when comparing versions. Rebinding a variable to another object can make code look like mutation even when the previous object remains unchanged.
Check library boundaries before changing a public API
Some functions accept any mapping; others explicitly require a dict. A new frozen mapping is not a dict subclass. Review callers and serialization paths before replacing a returned dict in a library API.
If a function only reads key-value pairs, accepting collections.abc.Mapping can describe its needs more accurately. If the function intentionally edits its input, it needs a different contract. Avoid silently making a copy inside every caller merely to preserve the appearance that nothing changed.
Test nested structures, empty input, unsupported fields and callers that previously mutated returned values. A change that catches accidental mutation may be desirable, but it can still be a compatibility change worth documenting.
A practical configuration review
For one real configuration object, list each field, its allowed type and whether ordering is meaningful. Decide which values can be shared safely. Build one test showing that an attempted outer reassignment fails and another showing the intended behavior of nested values.
As a self-check, does a frozendict containing a tuple of strings prove that a report is reproducible? No. Reproducibility also depends on input data, code, environment and any external state the computation uses. The configuration object addresses one part of that system.
Continue with Python copying and aliasing. The central skill is following object references, then choosing a container whose guarantees match the requirement.
Sources and scope
- Python 3.15 built-in types: current API, hashability and type relationship.
- PEP 814: design, shallow construction, nested-value example and union behavior.
Checked October 22, 2026. The configuration scenarios and review workflow are original. No speed or memory advantage is asserted. The excerpts were reviewed against the documentation but were not executed in this review because the available interpreter is Python 3.12.