skip to content

MappingProxyType and Immutability

types.MappingProxyType gives a read-only window onto a live mapping, which is what a class __dict__ is, and the values behind it stay mutable. Interviewers ask how you hand out config safely.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why is types.MappingProxyType only a shallow immutability guarantee?

level: middleimportance: must knowfreq 40%

answer

  1. One level deep, and no further
  2. The values keep their own methods
  3. append on a nested list still works
  4. Freeze the values: tuple, frozenset, nested proxy
  5. Same distinction as copy versus deepcopy

basics

~20 s

It 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 s

The 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 lines
python
from 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

What does types.MappingProxyType give you when you wrap a dict in it?

level: juniorimportance: should knowfreq 30%

basics

~20 s

A read-only window onto that same dict. Reads work as usual, but item assignment and deletion raise TypeError, and mutating methods such as update are absent. Nothing is copied, so later edits by the owner show through.

open as a page

When should you expose config as a types.MappingProxyType rather than a copy?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Use the proxy when callers should see live state and must not write it: it costs one wrapper object and no copying. Use a copy when callers need a stable snapshot or their own mutable working set, and accept the per-call cost.

open as a page

Why does a class's __dict__ come back as a mappingproxy rather than a dict?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

Because the type machinery must be told about every change to a class namespace. Handing it out wrapped forces writes through setattr, which updates the internal bookkeeping CPython keeps for attribute lookup; a raw dict write would leave that stale.

open as a page