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.

Read-only Mapping parameter versus invariant dict parameter dict of str to bool, a dict subclass, and MappingProxyType all satisfy Mapping of str to int, but dict of str to int rejects dict of str to bool with an arg-type error. dict[str, int] dict[str, bool] Counter subclass MappingProxyType param: Mapping[str, int] param: dict[str, int] all accept ✓ exact type only
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]. 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.

Soundness argument for dict invariance If dict of str to bool were accepted as dict of str to int, the callee could assign an int and break the caller's bool invariant, so the write step is what forces invariance. the write is what forbids covariance caller owns dict[str, bool] callee sees dict[str, int] callee writes d["x"] = 5 now caller's dict[str, bool] holds 5 — unsound, so mypy rejects step 1
Because a callee could write an int back through the shared reference, the value type must be invariant.
# 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:

Covariance in the Mapping value type Because bool is a subtype of int and Mapping has no setitem, Mapping of str to bool passes where Mapping of str to int is expected, while invariant dict blocks the same substitution. bool subtype of int int Mapping[str, bool] no __setitem__ → covariant dict[str, bool] writable → invariant expects Mapping[str, int]
A read-only Mapping lets value subtypes through; a writable dict stays invariant and blocks them.
# 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.

Method sets: Mapping versus MutableMapping MutableMapping adds setitem, delitem, update, pop, and setdefault on top of the read-only getitem, get, keys, values, items, and len that Mapping already provides. Mapping[K, V] — read only __getitem__ · get · keys · values items · __contains__ · __len__ MutableMapping adds __setitem__ · __delitem__ · update pop · popitem · clear · setdefault extends ▲ pick by what you call only look up → Mapping insert / delete → MutableMapping MappingProxyType fails the lower box
MutableMapping is Mapping plus the write half of the interface; annotate with the smallest box your body actually needs.
# 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.

Runtime vs static analysis Annotating a parameter 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.

Mapping[str, object] as the widest read-only mapping int, str, bytes, and float value types all sit below object, so mappings of each satisfy Mapping of str to object for key-only work. Mapping[str, object] accepts any value type Mapping[str, int] Mapping[str, str] Mapping[str, bytes] Mapping[str, float] every value type is a subtype of object
Widening the value slot to object makes one signature accept every string-keyed mapping for key-only work.
# 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.

Choosing a mapping parameter annotation If the body writes keys use MutableMapping, otherwise if it reads typed values use Mapping of K to V, otherwise for key-only work use Mapping of str to object. does the body write keys? setitem, delitem, update, pop yes no MutableMapping[K, V] read only — read typed values? or only touch keys? values keys Mapping[K, V] Mapping[str, object]
Pick the annotation from what the body does, not from the concrete type the caller happens to pass.
  • dict[K, V] on a read-only parameter. Rejects dict[K, subtype], subclasses, and proxies with mypy [arg-type] / pyright reportArgumentType. Use Mapping[K, V].
  • Mapping[K, V] where you assign keys. Mapping has no __setitem__; mypy reports "Mapping[K, V]" has no attribute "__setitem__" [index] / [attr-defined]. Use MutableMapping[K, V].
  • Importing Mapping from typing. Deprecated alias on 3.9+; ruff UP035. Import from collections.abc.
  • Assuming Mapping is covariant in the key. It is invariant in K; passing Mapping[bool, V] where Mapping[int, V] is expected still fails [arg-type]. Only the value type is covariant.
  • Reaching for Mapping[str, object] and then indexing values. The object value type blocks arithmetic and attribute access with [operator] / [attr-defined] until you isinstance-narrow. Widen only for key-only work; keep Mapping[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.

Back to Typing Collections and collections.abc