Mapping vs dict for Read-Only Arguments
TL;DR — For a parameter you only read from, annotate Mapping[K, V], not dict[K, V]. Mapping is covariant in its value type, so it accepts dict[K, subtype] and any dict subclass or MappingProxyType. Use dict[K, V] or MutableMapping[K, V] only when the function actually inserts, updates, or deletes keys.
Configuration objects, lookup tables, and parsed payloads are usually read, not written, inside the functions that receive them. Typing such a parameter dict[str, int] over-promises: it says “I might mutate this, and it must be exactly a dict.” Mapping[str, int] says only “I will look things up,” which matches the code and widens the caller set considerably. Both Mapping and MutableMapping come from collections.abc; the typing.Mapping re-exports are deprecated on Python 3.9+ and ruff UP035 will rewrite them.
Mapping[str, int] accepts value-subtype dicts, subclasses, and read-only proxies; dict[str, int] demands an exact match.dict is invariant in the value type
Like list, dict is invariant in both its key and value parameters. So dict[str, bool] is not accepted where dict[str, int] is expected, even though bool is a subtype of int. Invariance is required for soundness: a function holding a mutable dict[str, int] could assign d["x"] = 5, corrupting a caller’s dict[str, bool].
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
def total_weight(weights: dict[str, int]) -> int:
return sum(weights.values())
flags: dict[str, bool] = {"enabled": True, "verbose": False}
total_weight(flags)
# mypy: Argument 1 to "total_weight" has incompatible type "dict[str, bool]";
# expected "dict[str, int]" [arg-type]
# pyright: reportArgumentType
The function only reads weights, so this rejection is a pure annotation artifact.
Mapping is covariant in the value and accepts more
Mapping[K, V] from collections.abc is covariant in V (and invariant in K, since keys are on both sides of lookup). Switching the annotation accepts the value-subtype dict, plus dict subclasses like collections.Counter and read-only types.MappingProxyType:
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections import Counter
from collections.abc import Mapping
from types import MappingProxyType
def total_weight(weights: Mapping[str, int]) -> int:
return sum(weights.values())
total_weight({"a": 1, "b": 2}) # dict[str, int] ✓
total_weight({"enabled": True}) # dict[str, bool] ✓ (covariant value)
total_weight(Counter("mississippi")) # Counter subclass ✓
total_weight(MappingProxyType({"a": 3})) # read-only proxy ✓
Mapping provides __getitem__, get, keys, values, items, __contains__, __len__, and iteration — everything a read-only consumer needs. It withholds only the mutation methods.
MutableMapping when you write keys
When the function inserts, updates, pops, or clears keys, annotate MutableMapping[K, V]. It advertises __setitem__, __delitem__, update, pop, and setdefault, and it correctly rejects an immutable MappingProxyType at the call site instead of at a runtime TypeError.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import MutableMapping
def apply_defaults(cfg: MutableMapping[str, str], defaults: Mapping[str, str]) -> None:
for key, value in defaults.items():
cfg.setdefault(key, value) # needs __setitem__ / setdefault
from types import MappingProxyType
apply_defaults(MappingProxyType({}), {"env": "prod"})
# mypy: Argument 1 has incompatible type "MappingProxyType[str, str]";
# expected "MutableMapping[str, str]" [arg-type]
Note the pattern: the parameter you mutate is MutableMapping, and the read-only defaults alongside it is Mapping. That split documents exactly which argument the function may change.
Mapping[str, int] does not make the object immutable at runtime — if a caller passes a plain dict, the underlying object is still mutable and other code holding the same reference can change it. The annotation only stops *this* function from calling mutators (mypy would report [attr-defined] on cfg["k"] = v). For a genuinely read-only object at runtime, wrap it in types.MappingProxyType before sharing it.
Widening the value type with Mapping[K, object]
Covariance unlocks a useful pattern: a function that only inspects keys, or reads heterogeneous values generically, can accept Mapping[str, object] and take any string-keyed mapping regardless of value type. Because every value type is a subtype of object, Mapping[str, int], Mapping[str, str], and Mapping[str, bytes] all satisfy it.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Mapping
def audit_keys(record: Mapping[str, object]) -> list[str]:
return sorted(record) # iterates keys only, value type irrelevant
audit_keys({"id": 7, "name": "prod"}) # dict[str, int | str] ✓
audit_keys({"weight": 1.5}) # dict[str, float] ✓
This is the mapping analogue of Sequence[object] for key-only work. Do not reach for it when you actually read typed values — object values force a cast or isinstance narrowing before use. When the per-key value types are meaningful, a TypedDict preserves them where a widened Mapping would erase them.
Common mistakes
dict[K, V]on a read-only parameter. Rejectsdict[K, subtype], subclasses, and proxies with mypy[arg-type]/ pyrightreportArgumentType. UseMapping[K, V].Mapping[K, V]where you assign keys.Mappinghas no__setitem__; mypy reports"Mapping[K, V]" has no attribute "__setitem__"[index]/[attr-defined]. UseMutableMapping[K, V].- Importing
Mappingfromtyping. Deprecated alias on 3.9+; ruffUP035. Import fromcollections.abc. - Assuming
Mappingis covariant in the key. It is invariant inK; passingMapping[bool, V]whereMapping[int, V]is expected still fails[arg-type]. Only the value type is covariant.
FAQ
Does Mapping[str, int] accept a TypedDict?
Yes for reading — a TypedDict is a dict and satisfies Mapping[str, object], though value types must be compatible. If you need the precise per-key value types, keep the TypedDict annotation instead of widening to Mapping.
Should a function return Mapping or dict?
Return dict[K, V] when callers legitimately mutate the result; return Mapping[K, V] to signal a read-only view and keep the backing implementation swappable. For return types the concrete dict is often the friendlier default.