Skip to content
← All articles

Counter: counting, tallying, multiset arithmetic

The single most reinvented container in the standard library, the heap hiding inside most_common(n), and why a bare `Counter` annotation is a type error.

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.