Dict and set patterns — comprehensions, defaultdict, Counter, and set algebra
Beyond plain dict/set literals — comprehensions that build them directly, defaultdict and Counter for the 'group or count things' pattern that shows up constantly, and the set algebra operators that replace hand-written membership loops.
4 min read
Dict and set comprehensions: the same idea as a list comprehension
words = ["apple", "kiwi", "fig", "banana"]
{w: len(w) for w in words} # {'apple': 5, 'kiwi': 4, 'fig': 3, 'banana': 6}
{len(w) for w in words} # {5, 4, 3, 6} — a set, deduplicated automatically
{w: len(w) for w in words if len(w) > 3} # only entries where the condition holdsA dict comprehension ({key: value for ...}) and a set comprehension ({expr for ...}) follow the exact same shape as a list comprehension (covered in the list comprehensions lesson) — build a new collection by transforming and optionally filtering an existing iterable, in one expression instead of a manual loop with .append() or [key] = value. The only visible difference from a set literal is the presence of : — {w: len(w) for w in words} is a dict, {len(w) for w in words} is a set.
defaultdict: skip the "is this key already there?" check
from collections import defaultdict
# Without defaultdict — the check is mandatory every time
groups = {}
for word in words:
key = word[0]
if key not in groups:
groups[key] = []
groups[key].append(word)
# With defaultdict — the check disappears entirely
groups = defaultdict(list)
for word in words:
groups[word[0]].append(word) # missing key auto-creates an empty list, THEN appendsdefaultdict(list) takes a factory function — here, list — and calls it automatically to create a value the first time a missing key is accessed, instead of raising KeyError. This is exactly the "group items under a key" pattern that comes up constantly (grouping words by first letter, grouping records by category, building an adjacency list for a graph), and defaultdict removes the manual existence check every single time it happens. defaultdict(int) is the same idea for counting: counts[word] += 1 works immediately, because a missing key defaults to int(), which is 0.
Counter: defaultdict(int), purpose-built for counting
from collections import Counter
votes = ["red", "blue", "red", "green", "red", "blue"]
tally = Counter(votes)
tally # Counter({'red': 3, 'blue': 2, 'green': 1})
tally.most_common(2) # [('red', 3), ('blue', 2)] — top N, sorted descending
tally["yellow"] # 0 — missing keys return 0, never KeyErrorCounter is a dict subclass specifically for tallying — passing it any iterable counts occurrences of each item in one call, no loop required. .most_common(n) is the feature that makes it worth reaching for over a plain defaultdict(int): getting the top N most frequent items, sorted, is a one-line call instead of sorted(counts.items(), key=lambda x: -x[1])[:n].
Set algebra: the operators that replace manual membership loops
admins = {"ada", "grace", "linus"}
online = {"grace", "linus", "kai"}
admins & online # {'grace', 'linus'} — intersection: in both
admins | online # {'ada', 'grace', 'linus', 'kai'} — union: in either
admins - online # {'ada'} — difference: in admins, not online
admins ^ online # {'ada', 'kai'} — symmetric difference: in exactly oneThese four operators (&, |, -, ^) replace what would otherwise be a hand-written loop with membership checks — "which admins are currently online" is admins & online, one operation, evaluated using each set's underlying hash table rather than scanning either collection element by element. Reaching for set algebra instead of a loop with if x in other_set is both shorter and faster for anything beyond a handful of items.
frozenset: an immutable set, usable as a dict key
seen_pairs = set()
seen_pairs.add(frozenset({"alice", "bob"})) # a plain {"alice", "bob"} set would TypeError here — unhashable
frozenset({"bob", "alice"}) in seen_pairs # True — order inside doesn't matter for equalityA plain set is mutable, which means it's unhashable and can't be a dict key or a member of another set — the same restriction that applies to lists (covered in the collections lesson). frozenset is set's immutable counterpart, existing specifically to be hashable when a set-like collection of values needs to be used as a key or stored inside another set, such as tracking unordered pairs that have already been processed.
Merging dicts: | and ** unpacking
defaults = {"theme": "dark", "font_size": 14}
overrides = {"font_size": 18}
defaults | overrides # {'theme': 'dark', 'font_size': 18} — right side wins on conflict
{**defaults, **overrides} # identical result — the older way, via unpacking| between two dicts (added in Python 3.9) merges them into a new dict, with the right-hand operand's values winning on any key conflict — this is now the idiomatic way to layer an overrides dict on top of a defaults dict. {**a, **b} does the same merge via dict unpacking and still appears often in code written before 3.9, but | is the clearer, more direct spelling for new code.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What happens when you access a missing key on a `defaultdict(list)`?
2. What does `Counter(votes).most_common(2)` return?
3. What does `admins & online` compute, if both are sets?
4. Why can't a plain `set` be used as a dictionary key or stored inside another set?