Every code review of a data-processing codebase turns up the same loop:
counts = {}
for word in words:
if word in counts:
counts[word] += 1
else:
counts[word] = 1
Five lines, one branch, and a membership test on every iteration. The standard library has shipped the replacement since Python 2.7:
from collections import Counter
counts = Counter(words)
Counter is a dict subclass whose missing keys read as 0 instead of
raising. That single change is what removes the branch — counts[word] += 1
works on a key that has never been seen. Note carefully that it does not
insert on read the way defaultdict does; counts["never-seen"] returns 0
and leaves the mapping unchanged. len(counts) is stable under lookups. That
asymmetry with defaultdict is worth committing to memory, because the two
containers look interchangeable and are not.
The API you actually use
| Call | What it gives you |
|---|---|
Counter(iterable) |
tally of elements |
Counter(mapping) |
pre-seeded counts |
c.most_common(n) |
the n highest-count pairs |
c.total() |
sum of counts (3.10+) |
c.elements() |
each element repeated count times |
c.update(other) / c.subtract(other) |
in-place add / subtract |
| + - & | | multiset arithmetic |
most_common() with no argument sorts everything: $O(n \log n)$. With an
argument it uses heapq.nlargest, which is $O(n \log k)$ — for the common
“top 10 of a million” case that is the difference between a full sort of a
million items and a ten-element heap. If you find yourself writing
sorted(counts.items(), key=..., reverse=True)[:10], you are paying for a
sort you did not need.
Multiset arithmetic, and the sign rule
The operators treat a Counter as a multiset, and they all drop
non-positive results:
Counter(a=3, b=1) - Counter(a=1, b=4) # Counter({'a': 2}) -- no 'b': -3
Counter(a=3, b=1) & Counter(a=1, b=4) # Counter({'a': 1, 'b': 1}) -- min
Counter(a=3, b=1) | Counter(a=1, b=4) # Counter({'b': 4, 'a': 3}) -- max
If you want the signed difference, use subtract(), which mutates in place
and keeps zeros and negatives. This is the single most common surprise in the
API: - is a multiset operation, subtract() is arithmetic. Reaching for
- when you wanted to compute a delta silently discards every key that went
down.
💡You are diffing two inventory snapshots and want every SKU whose click to reveal
count changed, in either direction. Why is after - before wrong, and what do
you write instead?
- discards non-positive results, so every SKU that decreased vanishes from
the output and every SKU that stayed level vanishes too. You get “things that
went up”, not “things that changed” — and the bug is invisible because the
answer is a plausible non-empty Counter.
The signed version:
delta = Counter(after)
delta.subtract(before)
changed = {sku: n for sku, n in delta.items() if n}
subtract() mutates in place and keeps zeros and negatives, so the filter is
yours to write. Copy first (Counter(after)) unless you want the caller’s
snapshot mutated — a Counter is a mutable dict like any other.
Ties are insertion-ordered, which means they are input-ordered
most_common() breaks ties by first-seen order, because Counter is a dict
and dict preserves insertion order. That makes the output a function of the
order your input happened to arrive in. It is deterministic, but it is not
stable across runs when the input comes from a set, a directory listing, a
thread pool, or a database without an ORDER BY.
Any “top N” that a human will compare between runs needs an explicit tie-break:
ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
Descending by count, ascending by key. Now two runs over the same multiset agree regardless of arrival order.
Typing
Counter is generic in its element type, not in its value type — the
values are always int:
counts: Counter[str] = Counter()
A bare Counter annotation is an error under --disallow-any-generics,
which --strict turns on. That is a useful error: it forces you to say what
you are counting, and it catches the case where you meant
Counter[tuple[str, str]] and wrote something that accepts anything.
One version note for anyone supporting more than one interpreter:
Counter.__xor__ (the ^ operator, symmetric difference) is new in 3.15.
Do not use it in code that must also run on 3.14.
💡Counter extends dict. What does Counter(...) == dict(...)
click to reveal
do, and is a Counter with a zero count equal to one without the key?
Equality is inherited from dict, so it compares keys and values, and a
Counter compares equal to a plain dict with the same contents:
Counter(a=1) == {"a": 1} is True.
Because of that, Counter(a=1, b=0) != Counter(a=1) — the zero-valued key is
a real entry. Counting to zero and deleting are different states, and only the
arithmetic operators drop zeros. If you built a counter by subtract() and
want to compare it against an expected tally, run
+counter (unary plus) first: it returns a new Counter with all
non-positive entries stripped.