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].

# 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.

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.

# 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. 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.

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