Why is types.MappingProxyType only a shallow immutability guarantee?
answer
- One level deep, and no further
- The values keep their own methods
- append on a nested list still works
- Freeze the values: tuple, frozenset, nested proxy
- Same distinction as copy versus deepcopy
basics
~20 sIt freezes the key-to-value bindings, not the objects the values point at. A list or nested dict fetched through the proxy can still be mutated in place, so a caller changes your state without ever assigning to a key.
solid answer
~40 sThe proxy intercepts operations on the mapping itself — assignment, deletion, the mutating methods — and nothing else. `proxy["services"].append("x")` never touches the mapping: it reads the value out and then mutates that list through the list's own methods, which the proxy has no say over. So a read-only view over a config whose values are lists, sets, dicts or ordinary mutable objects is protection in name only. Depth has to come from the values: tuples and `frozenset` instead of lists and sets, a nested `types.MappingProxyType` for each nested mapping, frozen dataclasses for structured values, or `copy.deepcopy` at the boundary when you would rather pay the copy than reason about aliasing. In review, say which claim you are making — "callers cannot rebind keys" is far weaker than "callers cannot change anything".
code
python · 12 linesfrom types import MappingProxyType
graph = {"payments": ["ledger", "fx"], "ledger": []}
view = MappingProxyType(graph)
try:
view["payments"] = []
except TypeError as exc:
print("blocked:", exc)
view["payments"].append("smuggled") # no proxy on this path
print(graph["payments"]) # ['ledger', 'fx', 'smuggled']go deeper
Remember the one-sentence version: the proxy protects the dict, not what is inside it. A list stored as a value can still be appended to through the proxy, and no exception is raised.
Explain the mechanism, not just the outcome: reading a value hands back the very same object the mapping holds, so the caller then works with that object's own methods and the proxy is no longer on the call path.
Show the mitigation and its cost: freeze values at load time into tuples, frozensets and nested proxies, or deep-copy at the boundary, and be clear which guarantee you are giving reviewers and callers.
Frame it as a shared-state policy. Decide whether mutable data crosses module boundaries at all, and prefer immutable value types over defensive wrappers that only look protective in code review.
## What the wrapper can and cannot see A `mappingproxy` sits between a caller and one mapping object. It can refuse the operations that go *through* it: `proxy[k] = v`, `del proxy[k]`, and the mutating methods it declines to expose. It cannot see anything a caller does *after* a successful read. The moment `proxy["services"]` returns the list, the caller holds a direct reference to that list — the same object the wrapped dict holds — and every list method is available on it. `append`, `sort`, `clear`, slice assignment, item assignment on a nested dict: all of it lands in your data structure, and the proxy is not on the call path. That is the whole of the shallow/deep distinction, and it is exactly the same distinction as `copy.copy` versus `copy.deepcopy`, or a tuple containing a list. Immutability in Python is a property of *each object*, never of a container's promise about the objects inside it. ## What that looks like in an outage Consider an inventory sync between two systems, driven by a config mapping that describes a 17-service dependency graph: each key is a service, each value the list of services that must be reconciled before it. The job hands each per-service handler a `MappingProxyType` over that mapping and considers the shared state protected. During a partial-failure rollback, one handler decides it should not retry dependencies it has already unwound, and does `deps = view[service]` followed by `deps.remove(other)`. No `TypeError`, no warning — it mutated the shared graph. The next service in the run reads a dependency list with entries missing, reconciles in the wrong order, and the rollback leaves the two systems inconsistent. The proxy did precisely what it promised, and the promise was never the one the author believed they had. The failure mode is nasty because it is *silent* and *shared*. Had the handler written `view[service] = []`, the process would have raised at that line, in that handler, with the mistake in the traceback. Mutating in place produces no event at all — just wrong data later, in someone else's code. ## Making it actually deep There is no flag to make a proxy recursive; you build depth out of immutable values: * **Replace mutable containers with immutable ones.** Lists become tuples, sets become `frozenset`, and nested dicts become nested `MappingProxyType`. A small recursive freeze function over the config at load time is usually enough, and it is the cheapest option because it happens once. * **Use frozen value objects.** A dataclass declared with `frozen=True` refuses attribute assignment, so a config value that is a structured record stops being a mutation vector. `typing.NamedTuple` and `enum` members serve the same purpose. * **Deep-copy at the boundary instead.** If callers genuinely need their own mutable working copy, hand them `copy.deepcopy(dict(proxy))` and stop pretending the shared object is protected. Note that `copy.deepcopy(proxy)` itself fails with `TypeError: cannot pickle 'mappingproxy' object` — convert to a dict first. * **Or hand out no mapping at all.** An accessor function or a small read-only object with named properties exposes exactly the fields callers need and nothing else, which sidesteps the question. ## Strings are the happy case When every value is a `str`, `int`, `bool`, `float`, `None`, tuple of those, or a frozen record, a `mappingproxy` really is total immutability for the caller, because rebinding a key is the only mutation that exists. That is why the pattern feels safe in the small examples people first meet — flat string config — and starts leaking the moment somebody nests a list under it. ## What to say in the interview Name the guarantee precisely: the proxy makes the *mapping* read-only for the holder of the proxy; it makes nothing else read-only, and the owner of the wrapped dict is unaffected either way. Then give the fix in one breath — freeze the values, or deep-copy at the boundary — and mention that the interesting bugs are the silent in-place ones, not the loud `TypeError`s. That combination of a precise claim plus the mitigation is what separates a middle answer from a recital.
- If every value in the wrapped dict is a str or an int, is the proxy then fully immutable for the caller?For the caller, yes — with only immutable values, rebinding a key is the only mutation available, and the proxy refuses that. The owner of the wrapped dict can still change it, so the caller can observe different contents over time. "Immutable for the caller" and "stable over time" are two different guarantees, and only the first one holds.
- How would you freeze a nested configuration mapping in one pass?Walk it recursively at load time: convert each nested dict to a types.MappingProxyType of its frozen contents, lists to tuples, sets to frozensets, and leave scalars alone. Do it once when the config is parsed, so the cost is paid at startup and every consumer afterwards holds something that cannot be mutated at any depth.
- Why is the in-place mutation bug harder to find than an attempted key assignment?An attempted key assignment raises TypeError on the exact line that made the mistake, with the culprit in the traceback. An in-place mutation of a nested value succeeds silently and corrupts shared state; the symptom shows up later, in different code that merely reads the mapping, so the traceback points nowhere near the cause.
A locked display cabinet with open-topped boxes inside: nobody can swap a box for another one, but anyone can reach in and rearrange what is in a box.
saying these in an interview costs you the question
- Calls it deep immutability of the whole structure
- Thinks appending to a nested list raises TypeError
- Believes the proxy deep-copies the values it returns
- Suggests a flag to make the proxy recursive
- Confuses it with copy.deepcopy at the boundary
- Assumes copy.deepcopy(proxy) works on the proxy itself