skip to content

Why annotate a parameter `collections.abc.Mapping[str, int]` rather than `dict[str, int]`?

level: seniorimportance: should knowfreq 40%

answer

  1. Interface versus one implementation
  2. Which callers does the signature exclude
  3. The abstract one has no item assignment
  4. Liberal in, specific out
  5. A bare string satisfies Sequence[str]

basics

~20 s

Mapping is the abstract interface, so the parameter accepts any mapping a caller already has, not only a real dict. It also states that the function will not mutate the argument, because the abstract Mapping has no item assignment.

solid answer

~40 s

`dict[str, int]` in a parameter is a nominal demand: the caller must hand over a `dict` or a subclass of one. Plenty of perfectly good mappings are not — `types.MappingProxyType`, `collections.ChainMap`, `os.environ`, a lazily backed custom mapping — so the caller is forced to build a throwaway copy just to satisfy the signature. `collections.abc.Mapping[str, int]` accepts all of them, and it also documents intent: the abstract `Mapping` has no item assignment, so a checker rejects mutation inside the body, and callers can see the argument is read-only. The mirror rule applies to returns: **accept the abstract type, return the concrete one**, so `-> dict[str, int]` gives the caller the full API instead of a narrowed view.

code

python · 14 lines
python
from collections import ChainMap
from collections.abc import Mapping
from types import MappingProxyType

def total_units(counts: Mapping[str, int]) -> int:
    return sum(counts.values())

defaults = {"SKU-1": 1, "SKU-2": 1}
layered = ChainMap({"SKU-1": 6800}, defaults)
frozen = MappingProxyType({"SKU-3": 12})

print(total_units({"SKU-1": 3}))
print(total_units(layered), isinstance(layered, dict))
print(total_units(frozen), isinstance(frozen, Mapping))

go deeper

for a junior

Know that collections.abc.Mapping and Sequence are the abstract interfaces behind dict and list, and that a parameter annotated with the abstract type accepts more kinds of argument. Writing dict[str, int] is not wrong, just narrower than it needs to be.

for a middle

Explain the mechanics: nominal matching means dict[str, int] excludes ChainMap, MappingProxyType and os.environ, and the abstract Mapping has no item assignment so mutation inside the body is rejected. Pair it with the accept-abstract, return-concrete habit.

for a senior

Show the cost of getting it wrong on a real API — a caller forced to copy a large mapping just to satisfy a signature — and the limit of the idea: a bare str satisfies Sequence[str], so over-abstracting a parameter can admit an argument that silently misbehaves.

for a principal

Own it as public-API policy: how wide the accepted types are across a library boundary, whether mutation is ever performed on a caller's object, and what a later narrowing would cost every consumer. The signature is the compatibility promise, so set the convention once.

### The two families `collections.abc` defines the abstract collection interfaces — `Mapping`, `MutableMapping`, `Sequence`, `MutableSequence`, `Set`, `MutableSet`, `Collection`, `Container`, `Sized`, `Hashable` — and PEP 585 in **Python 3.9** made them subscriptable, so `Mapping[str, int]` and `Sequence[str]` are written directly from `collections.abc` rather than through the deprecated `typing` re-exports. They pair with the concrete builtins: `dict` implements `MutableMapping`, `list` implements `MutableSequence`, `set` implements `MutableSet`. The abstract type names the *capability*; the builtin names *one implementation of it*. ### Why the concrete type in a parameter is over-narrow Python's static type system is nominal for classes: `dict[str, int]` matches `dict` and its subclasses and nothing else. That excludes a surprising amount of real code. `types.MappingProxyType` is the read-only view you get from `SomeClass.__dict__` and from any API that hands out an immutable config. `collections.ChainMap` layers defaults under overrides and is **not** a dict subclass. `os.environ` is a `MutableMapping`, not a dict. Any lazily-backed mapping — one that reads a row on demand rather than materialising everything — is written by implementing the ABC. So picture a pick-list builder whose entry point is `build_pick_list(counts: dict[str, int])`. A caller holding a `ChainMap` of per-warehouse overrides over a global default has exactly one way to comply: `dict(counts)`. On a **6,800-row** batch that is 6,800 pointless hash insertions and a second copy of the data in memory, done purely to satisfy an annotation that the function's body never needed — the body only iterated and looked things up. Widening the parameter to `Mapping[str, int]` deletes the copy and the constraint at once. There is a subtler widening too. Because the abstract read-only `Mapping` is covariant in its value type, a `Mapping[str, bool]` is acceptable where `Mapping[str, int]` is asked for, while `dict[str, bool]` is *not* acceptable for `dict[str, int]` — a mutable container has to be invariant, or the callee could write an `int` into the caller's `dict[str, bool]`. ### Why it also documents intent The abstract `Mapping` interface has `__getitem__`, `__len__`, `__iter__`, `get`, `keys`, `values`, `items`, `__contains__` — and no way to assign or delete an item. So annotating the parameter as `Mapping` makes the checker reject `counts[sku] = 0` inside the function. That is a contract in both directions: the reader knows their argument comes back untouched, and the author cannot quietly start mutating it in a later change without the signature having to change too. If the function genuinely does mutate what it is handed, `MutableMapping[str, int]` is the honest annotation and says so on the line where it matters. ### The mirror rule for return types Be liberal in what you accept, specific in what you return. A parameter typed `Mapping[str, int]` widens the set of callers; a *return* typed `Mapping[str, int]` narrows what the caller may do with a value you just built and own. If the function constructs and returns a fresh dict, annotate `-> dict[str, int]`: the caller gets `|=`, `setdefault`, item assignment and the concrete type without a cast. Returning the abstract type is right only when you are deliberately handing out a read-only view, and in that case return an actual read-only object such as a `types.MappingProxyType` rather than a dict that merely claims to be immutable in the annotation. ### The trap that makes this a senior question Abstraction can be widened too far, and the classic case is `Sequence[str]`. A `str` **is** a `Sequence[str]` — indexing a string yields strings — so a parameter annotated `Sequence[str]` happily accepts a single bare string, and the body then iterates it character by character. A pick-list builder handed `"SKU-1"` instead of `["SKU-1"]` produces five one-character entries and no error anywhere: not at the call, not in the checker, not at runtime. `bytes` has the same shape as a `Sequence[int]`. When the parameter is a collection of strings, `list[str]`, `tuple[str, ...]` or a runtime guard is often the safer choice, and this is exactly the judgement an interviewer is probing. ### How to choose, in one pass Look at what the body actually does with the argument. If it only looks keys up and iterates, the parameter is a `Mapping`. If it assigns or deletes, it is a `MutableMapping`. If it indexes and takes `len`, it is a `Sequence` — unless the elements are strings, where the bare-string hazard argues for the concrete type. Then annotate the return with whatever concrete object you are actually handing back. The result is a signature that admits every caller the implementation can serve and refuses the ones it cannot, which is the whole point of writing the types down.

  • When is `MutableMapping[str, int]` the right parameter annotation instead?
    When the function really does assign or delete items in the caller's object. `Mapping` has no item assignment, so a checker rejects `counts[sku] = 0` in the body; widening to `MutableMapping` states the side effect on the signature line where reviewers and callers see it. If instead the function should not touch the argument, keep `Mapping` and build a fresh dict internally.
  • Should a function that builds and returns a mapping annotate the return as Mapping?
    Usually no. Accept the abstract type, return the concrete one: `-> dict[str, int]` hands the caller the full API without a cast, and you own the object anyway. Return an abstract type only when you deliberately expose a read-only view, and then return something genuinely read-only such as `types.MappingProxyType` rather than a dict that the annotation merely pretends is immutable.
  • What goes wrong with a parameter annotated Sequence[str]?
    A bare `str` satisfies it, because indexing a string yields strings. A caller passing `"SKU-1"` where a collection of SKUs was meant gets five one-character items, with no complaint from the checker or the interpreter. When the elements are strings, prefer `list[str]` or `tuple[str, ...]`, or add an explicit `isinstance(x, str)` guard at the top of the function.

Asking for a dict is asking a supplier for goods in your own brand of crate; asking for a Mapping is asking for the goods on any pallet that a forklift can pick up.

saying these in an interview costs you the question

  • Says every mapping in Python is a dict subclass
  • Uses dict[str, int] on parameters purely out of habit
  • Returns Mapping for a dict the function just built
  • Thinks Mapping enforces immutability at runtime
  • Claims Sequence[str] rejects a bare string
  • Reaches for MutableMapping when nothing is mutated

context