Python

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.

Intermediate

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 holds

A 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 appends

defaultdict(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 KeyError

Counter 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 one

These 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 equality

A 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?