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.
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]. Variance is the formal rule (defined in PEP 483 and codified by PEP 484) that decides when Container[Sub] may stand in for Container[Super]. A type is covariant when subtypes flow through (Sub accepted for Super), contravariant when they flow the opposite way, and invariant when neither direction is allowed. Mutable containers must be invariant precisely because they are read and written through the same reference.
# 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 — the checker cannot see that total_weight never mutates, so it applies the conservative invariant rule that keeps genuinely mutating callers sound. The key parameter is invariant for a subtler reason: keys appear in __getitem__ (an output position, which wants covariance) and in __setitem__ and keys() iteration (input-flavoured positions), so no single variance is safe and dict fixes the key type exactly. That is why even dict[bool, int] is rejected where dict[int, int] is expected. On Python 3.8 you would write Dict[str, int] from typing; the built-in dict[str, int] subscription became legal at runtime in 3.9 via PEP 585, and works on 3.7–3.8 only inside from __future__ import annotations strings. The variance behaviour is identical across all of these spellings — this is the same invariance rule that makes list reject list[bool] where list[int] is wanted.
In practice the invariance bite shows up most often when parsing JSON. json.load is typed to return Any, but the moment you annotate a parsed payload as dict[str, int] and hand it to a helper expecting dict[str, int | str], mypy rejects the call with [arg-type]. Widening the helper parameter to Mapping[str, object] — rather than re-typing every caller — is almost always the cleaner fix. Note too that neither Mapping nor dict places a Hashable bound on the key parameter K at the type level, even though the runtime requires hashable keys; passing an unhashable key is a runtime TypeError, not something mypy or pyright will flag statically.
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). Covariance is sound here for the mirror-image reason that invariance was needed for dict: Mapping exposes no mutation methods, so there is no __setitem__ through which a callee could inject a value that violates the caller’s element type. With nothing writable, subtypes may safely flow through the value slot, and the checker relaxes the rule. 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. The covariance is declared on the ABC itself: in typeshed, Mapping is generic over _KT and a covariant _VT_co, which is why the substitution above type-checks without any annotation on your part. Counter passes because it is a genuine dict subclass, and MappingProxyType passes because it registers as a Mapping while deliberately omitting the setters. Import the name from collections.abc, not typing: from typing import Mapping still works but emits a DeprecationWarning path under PEP 585 and is rewritten by ruff UP035. Both mypy and pyright treat the collections.abc.Mapping and legacy typing.Mapping spellings as the same type, so the only cost of the old import is the lint, not a behavioural difference. When your function additionally needs ordering guarantees, remember Mapping says nothing about insertion order at the type level even though every dict since 3.7 preserves it at runtime.
The set of concrete types that satisfy Mapping[K, V] is deliberately broad: collections.ChainMap, collections.defaultdict, collections.OrderedDict, weakref.WeakValueDictionary, and types.MappingProxyType all subclass or register as Mapping, so a Mapping[str, int] parameter accepts every one of them without a cast. That is the practical payoff of programming to the ABC rather than the concrete class. One asymmetry is worth remembering when you read values: Mapping.get(key) is typed to return V | None (the two-argument get(key, default) overload returns V | _T), so calling .get() on a Mapping[str, int] yields int | None and forces you to handle the missing-key case — mypy reports [union-attr] if you use an int method on the result without narrowing, and pyright reports reportOptionalMemberAccess. Subscripting with m[key] instead returns a bare V, trading the static None for a possible runtime KeyError.
MutableMapping when you write keys
When the function inserts, updates, pops, or clears keys, annotate MutableMapping[K, V]. It advertises __setitem__, __delitem__, update, pop, popitem, clear, and setdefault on top of everything Mapping gives you, and it correctly rejects an immutable MappingProxyType at the call site instead of at a runtime TypeError. Crucially, MutableMapping is invariant in V — the moment you can write values, covariance is unsound again — so it behaves like dict for substitution purposes while still accepting any mutable mapping implementation, not just dict itself.
# 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. Pyright reports the same rejection as reportArgumentType, noting that MappingProxyType[str, str] is not assignable to MutableMapping[str, str] because the write methods are missing. This static catch is worth emphasising: without the annotation, MappingProxyType.__setitem__ raises TypeError: 'mappingproxy' object does not support item assignment only when the line executes, potentially deep in a rarely-hit branch. Prefer MutableMapping[K, V] over a bare dict[K, V] on a mutating parameter when you want to keep accepting other mutable mappings such as collections.OrderedDict, defaultdict, or a custom MutableMapping subclass; reach for the concrete dict[K, V] only when you specifically rely on dict-only behaviour. If your body never mutates, staying on Mapping also keeps the door open to callers passing a TypedDict, which is read-compatible but not a MutableMapping of arbitrary keys.
A canonical real-world MutableMapping[str, str] is os.environ: it is not a dict, so a parameter typed dict[str, str] would reject it, whereas MutableMapping[str, str] accepts it and correctly permits environ[key] = value. The same holds for configparser sections and cache objects that implement the mutable-mapping interface. When a function needs only to merge one mapping into another, the natural signature pairs a MutableMapping[K, V] target with a Mapping[K, V] source, mirroring dict.update, whose typeshed stub accepts any Mapping (or an iterable of key-value pairs). If you find yourself widening the target back to a concrete dict[K, V], ask whether you truly depend on a dict-only operator such as |= — the in-place merge added in Python 3.9 by PEP 584 is defined on dict, not on MutableMapping, so a function that uses it must annotate dict and mypy will report [operator] if you try it on a bare MutableMapping.
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 — object sits at the top of the type lattice, and value covariance means the top type accepts everything below 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, and mypy will reject record[k] + 1 with Unsupported operand types for + ("object" and "int") [operator] until you narrow. Note a subtle asymmetry: Mapping[str, object] works, but the invariant dict[str, object] does not accept a dict[str, int], because widening the value of a mutable dict would again be unsound — a reminder to reach for the read-only ABC precisely when you want this flexibility. If you only touch keys and never values, Mapping[str, Any] is a looser alternative that also silences the operator error, but object is the more honest choice because it forces an explicit narrowing rather than opting out of checking entirely; see Any and its escape-hatch trade-offs for why object beats Any here. When the per-key value types are meaningful, a TypedDict preserves them where a widened Mapping would erase them.
A middle option exists when the values are heterogeneous but drawn from a small, known set: Mapping[str, int | str] is more precise than Mapping[str, object] because reading a value gives you int | str to narrow rather than the near-opaque object. Reach for the union when the value domain is closed, and fall back to object only when it is genuinely open-ended. Either way, remember that covariance flows subtypes into the value slot, not out of it: a Mapping[str, object] parameter cannot be returned as a Mapping[str, int], so widening at the boundary never lets you smuggle a narrower type back out without a cast or an explicit re-validation. For module-level lookup tables the idiomatic spelling is CONFIG: Final[Mapping[str, int]] = {...} — typing.Final documents that the binding never rebinds, and Mapping hands consumers a read-only view type even though the backing object is a mutable dict.
Common mistakes
Most mapping-annotation errors collapse to a single decision — what does the body actually do with the argument? — so walk that tree before reaching for a type name.
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. - Reaching for
Mapping[str, object]and then indexing values. Theobjectvalue type blocks arithmetic and attribute access with[operator]/[attr-defined]until youisinstance-narrow. Widen only for key-only work; keepMapping[K, V]or a TypedDict when values are read.
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.