PYTHON / DICTIONARIES AND SETS
defaultdict, Counter, and OrderedDict
Use defaultdict for grouping and accumulating, Counter for frequencies and multiset math, and OrderedDict for reordering keys.
What you will learn
- Group items with defaultdict(list) and tally with defaultdict(int), no key pre-checks
- Count any iterable with Counter and rank results with most_common(n)
- Combine tallies with Counter +, -, and subtract(), knowing which drops negatives
- Reorder keys with OrderedDict.move_to_end and popitem(last=False)
Understanding defaultdict, Counter, and OrderedDict
All three of these types subclass dict, so every method you already know still works; each one only changes one specific behaviour. defaultdict changes what happens on a missing key lookup: instead of raising KeyError, it calls the zero-argument factory you passed in, stores that value under the key, and returns it. That single change is what lets you write by_letter[c].append(w) without a two-line check first, because the empty list is created on demand.
Counter changes missing-key lookups differently: c['missing'] returns 0 without inserting anything, so the mapping does not grow when you probe it. Its constructor accepts any iterable and counts elements, and it adds most_common, arithmetic operators, and subtract. The operators + and - treat counters as multisets and discard non-positive results, while subtract mutates in place and keeps negatives, which matters when you want to see a deficit rather than hide it.
OrderedDict changes nothing about lookups. Since Python 3.7 plain dicts already preserve insertion order, so what remains distinctive is movement and comparison: move_to_end reorders an existing key, popitem(last=False) pops the oldest entry, and equality between two OrderedDicts is order-sensitive. Note that assigning to an existing key never reorders it in either type; you must call move_to_end explicitly, which is exactly the primitive an LRU cache needs.
from collections import defaultdict, Counter, OrderedDict
words = ["pear", "plum", "apple", "peach", "avocado"]
by_letter = defaultdict(list)
for w in words:
by_letter[w[0]].append(w)
print(dict(by_letter))
letters = Counter("mississippi")
print(letters.most_common(3))
print(letters["z"], len(letters))
cache = OrderedDict(a=1, b=2, c=3)
cache.move_to_end("a")
print(list(cache))
print(cache.popitem(last=False))Each of these types is a dict with one behaviour swapped out: defaultdict fabricates missing values, Counter counts and does multiset math, and OrderedDict exposes key order as something you can move and compare.
Worked examples
A defaultdict lookup writes to the dict
Shows that indexing a defaultdict inserts the default, while get does not, and that clearing default_factory restores KeyError.
from collections import defaultdict
tally = defaultdict(int)
tally["a"] += 1
print(tally["b"])
print(dict(tally))
print(tally.get("c"), dict(tally))
tally.default_factory = None
try:
tally["d"]
except KeyError as e:
print("KeyError:", e)Example explained
Line 1tally['a'] += 1 works because the read half calls int() first, producing 0 to add to.
Line 2print(tally['b']) returns 0 but also stores 'b': 0, which is why the next line shows two keys.
Line 3tally.get('c') returns None and never touches default_factory, so the dict stays at two keys.
Line 4default_factory is a plain attribute; setting it to None makes the defaultdict behave like a normal dict again.
Counter arithmetic versus subtract
Demonstrates that the - operator drops non-positive counts while subtract keeps negatives in place.
from collections import Counter
stock = Counter(apple=5, pear=2)
sold = Counter(apple=6, pear=1, plum=3)
print(stock - sold)
print(stock + sold)
stock.subtract(sold)
print(stock)Example explained
Line 1stock - sold keeps only positive results, so apple (5-6) and plum (0-3) vanish entirely.
Line 2stock + sold sums matching keys and unions the key sets, treating absent keys as 0.
Line 3subtract returns None and mutates stock, so negative counts survive and reveal the shortfall.
Line 4Counter's repr lists entries from highest count to lowest, which is why pear leads the last line.
Order-sensitive equality and reordering
Compares dict and OrderedDict equality rules and shows that reassigning a key does not move it.
from collections import OrderedDict
a = {"x": 1, "y": 2}
b = {"y": 2, "x": 1}
print(a == b)
print(OrderedDict(a) == OrderedDict(b))
print(OrderedDict(a) == b)
lru = OrderedDict([("k1", 1), ("k2", 2)])
lru["k1"] = 99
print(list(lru))Example explained
Line 1Plain dicts compare by keys and values only, so differing order still gives True.
Line 2Two OrderedDicts compare order-sensitively, so the same pairs in a different order are unequal.
Line 3An OrderedDict compared against a plain dict falls back to the order-insensitive rule, giving True.
Line 4Assigning to the existing key k1 updates the value but leaves its position, so move_to_end is required to promote it.
Important notes
Setting or subtracting can leave zero and negative counts in a Counter; they remain real keys until you delete them, and unary +c is the shortcut that filters down to positive counts only.
Counter.total() exists only in Python 3.10 and later; use sum(c.values()) if you need to support older versions.
Common mistakes
Probing a defaultdict with d[key] to test presence: every probe inserts a default, so len(d) and later iteration include keys that were never really there. Use key in d or d.get(key).
Writing defaultdict(list()) or defaultdict([]) instead of defaultdict(list): the argument must be callable, so you get TypeError: first argument must be callable or None.
Using a Counter as a general value store and forgetting that a typo'd key returns 0 instead of raising KeyError, so the bug hides until the numbers come out wrong.
Try it yourself
Change, predict, then run
Given pairs = [("ana", 91), ("bo", 68), ("ana", 74), ("cy", 91)], build a defaultdict(list) mapping each name to its scores, then print a Counter of the scores and its most_common(1).
Open the Python workspaceCheck your understanding
groups = defaultdict(list) already holds 3 keys. Code then loops over 100 names that were never added and runs `if groups[name]: ...` for each. What is len(groups) afterwards?
- 103, because indexing a defaultdict stores a fresh default for every missing key
- 3, because an empty list is falsy so nothing is kept
- 3, because default_factory only runs when you assign to a key
- It raises KeyError on the first unseen name since no value was ever stored
Show answer
Indexing a defaultdict with a missing key calls default_factory, inserts the result, and returns it, so all 100 probes leave empty lists behind and len becomes 103. The falsy-empty-list answer is tempting because the if body never runs, but truthiness is evaluated after the insertion has already happened; use name in groups to look without writing.