The parent note taught you the machine : braces {}, a colon : for dicts, no colon for sets,
a for at the back, an optional if. This page is the crash-test lab . We will list every
kind of situation a dict/set comprehension can be dropped into — weird keys, collisions, empty
inputs, nested loops, filters, ternaries — and then work each one until you have physically seen
the outcome. When you finish, no exam question can show you a shape you haven't already met.
This page assumes only what the parent parent note
built. If a term feels new, it is re-earned here.
Definition Three everyday tools we lean on — re-earned here
range(a, b) produces the whole numbers starting at a and stopping just before b.
So range(1, 5) is 1, 2, 3, 4 (the right end is excluded — count on your fingers: four numbers).
It is simply a machine that hands out consecutive integers, one per loop pass.
x % 3 (read "x mod 3") is the remainder left after dividing x by 3. Think of laying
x cookies into rows of 3: % is how many are left over that couldn't fill a row. So 7 % 3 == 1,
6 % 3 == 0, 2 % 3 == 2. Remainders mod 3 can only ever be 0, 1, or 2 — that is why it will
cause key collisions later (only 3 possible keys).
n ** 2 and n ** 3 — the ** is Python's power (exponent) operator, not two
multiplications. n ** 2 means "n times itself" = n 2 (the square); n ** 3 means
"n times itself three times" = n 3 (the cube). So 4 ** 3 is 4 × 4 × 4 = 64 . Read the
two stars as a little raised exponent.
Definition "Hashable" — the property a dict key must have
To store a key, a dict runs it through a hash function : a machine that turns the key into a fixed
number (its slot address ), so lookups are instant. A value is hashable when (1) it can be
handed to that machine and (2) its hash never changes for its whole life. Immutable things —
numbers, strings, and tuples of those — qualify, because they can never change. Mutable things —
lists, sets, dicts — do not , because if they changed after being filed, the dict could never find
them again. So: a dict key (and a set element) must be hashable. See Hashing and hashable types .
Intuition Dicts remember their insertion order (Python 3.7+)
Since Python 3.7, a dict keeps keys in the order they were first inserted . So when we say
"last write wins" for a colliding key, the value updates but the key stays in its original
slot — it does not jump to the end. This matters whenever you predict the printed order of a
comprehension's output.
Every comprehension question is really one row of this table. Each cell is a distinct behaviour
you must be able to predict on sight.
#
Case class
What makes it tricky
Example that hits it
A
Basic dict, unique keys
nothing — the baseline
Ex 1
B
Key collision (two items, same key)
later value overwrites
Ex 2
C
Collision fixed by grouping into lists
keep everything
Ex 3
D
Filter if at the end
some items produce no entry
Ex 4
E
Ternary if/else in the value
every item entered, value chosen
Ex 5
F
Set comprehension, duplicate values dropped
count shrinks
Ex 6
G
Empty / degenerate input
empty iterable, {} trap
Ex 7
H
Nested comprehension (two fors)
Cartesian sweep
Ex 8
I
Unhashable key — the crash case
list can't be a key
Ex 9
J
Real-world word problem + exam twist
reading intent into syntax
Ex 10
K
Chained filters (if cond1 if cond2)
both must pass
Ex 11
Intuition How to read a comprehension out loud
Always translate right-to-left into English first: read the for clause ("for each item…"),
then any if ("…but only when…"), then the head ("…build this pair / value"). Do that
before you predict anything.
Look at Figure s01 below: it labels the three parts of a comprehension in the exact order you
should read them — the black arrows point to the for (part 1) and the if (part 2), and the red
arrow singles out the head with its colon (part 3), the piece that decides dict-vs-set. Trace the
arrows in numbered order before you read any real code.
Statement. Build a dict mapping each number 1..4 to its cube.
{n: n ** 3 for n in range ( 1 , 5 )}
Forecast: how many entries, and what is the value at key 3? Guess before reading on.
Read the for. range(1, 5) yields 1, 2, 3, 4 — four items (remember: the right end 5
is excluded).
Why this step? The number of distinct keys can never exceed the number of items; counting the
source first bounds the answer.
Build each pair. For each n, head is n : n**3 (recall ** is the power operator, so
n**3 is n 3 , the cube). So 1:1, 2:8, 3:27, 4:64.
Why this step? The head is literally what we store — key on the left of the colon, value on the right.
All keys 1,2,3,4 are distinct , so no overwriting occurs.
Why this step? Cell A is the case where nothing collides — establishes the reference behaviour.
Result: {1: 1, 2: 8, 3: 27, 4: 64} — 4 entries, value at 3 is 27.
Verify: 4**3 == 64 and there are exactly 4 keys. ✅
Statement. ```python
{x % 3: x for x in range(6)}
**Forecast:** you might expect 6 entries. How many actually survive?
1. **List the (key, value) pairs in order.** `x=0→(0,0)`, `1→(1,1)`, `2→(2,2)`, `3→(0,3)`, `4→(1,4)`, `5→(2,5)`.
*Why this step?* Order matters here — a dict processes pairs left to right, and a repeated key
**replaces** the earlier value. (Recall `x % 3` can only be `0, 1, 2`, so keys *must* repeat.)
2. **Group by key.** Key `0` appears for `x=0` then `x=3`; key `1` for `x=1` then `x=4`; key `2` for `x=2` then `x=5`.
*Why this step?* A dict can hold each key **once**. Seeing the duplicates tells you which values die.
3. **Apply "last write wins".** Key `0`→`3`, key `1`→`4`, key `2`→`5`. Because of insertion order,
the keys print as `0, 1, 2` (their first-seen order), even though the *values* came from later items.
*Why this step?* The comprehension is equivalent to a loop doing `d[k]=v`; the final assignment
to each key is the one that remains, but the key's *position* was fixed on first insertion.
**Result:** `{0: 3, 1: 4, 2: 5}` — only 3 entries.
**Verify:** `3 % 3 == 0`, `4 % 3 == 1`, `5 % 3 == 2`, and length is `3`. ✅
Look at Figure s02 below: six input boxes (x=0..5) at the top fan down into three key buckets.
Six thin black arrows would land, but only the red arrows — the last write into each bucket —
survive, and the red -> 3 / -> 4 / -> 5 under the buckets is the final stored value. The picture
is the "last write wins" rule made visible.
defaultdict?
A normal dict raises an error if you touch a key that doesn't exist yet: d[k].append(x) fails
because d[k] isn't there. A defaultdict(list) (from the collections module) is a dict that,
the first time you touch a missing key, auto-creates an empty list [] for it — so
d[k].append(x) just works. Think of it as a dict that hands you a fresh empty box the moment you
reach for a shelf that was empty. Nothing else about it differs from a normal dict.
Statement. From range(6), group numbers by their remainder mod 3, keeping all of them.
A one-line comprehension can't grow a list in place, so we use a two-step pattern:
from collections import defaultdict
groups = defaultdict( list )
for x in range ( 6 ):
groups[x % 3 ].append(x)
result = {k: v for k, v in groups.items()}
Forecast: what list sits under key 0?
Why not a pure comprehension? A dict comprehension evaluates one value per key; it cannot
accumulate. So we accumulate in a loop (using a defaultdict so the empty list is auto-created),
then snapshot into a dict with a comprehension.
(A "snapshot" here just means: once the loop has finished filling groups, we take a single fresh
copy of its current key→list contents into a new dict — a still photo of the finished state, not a
live link.)
Why this step? This shows the boundary of the tool — cell B loses data, cell C saves it by
changing the value type from a number to a list .
Trace the append. Key 0 collects x=0,3; key 1 collects 1,4; key 2 collects 2,5.
Why this step? .append grows the existing list instead of replacing it — the opposite of "last write wins".
Snapshot into a plain dict. {k: v for k, v in groups.items()} copies each key with its full list.
Why this step? .items() yields (key, value) pairs; we unpack k, v and rebuild a clean dict.
Result: {0: [0, 3], 1: [1, 4], 2: [2, 5]}.
Verify: every original number 0..5 appears exactly once across the lists (total length 6). ✅
Statement. Map each number 1..10 to its square, but only the multiples of 3 .
{n: n ** 2 for n in range ( 1 , 11 ) if n % 3 == 0 }
Forecast: which keys survive the filter?
The if runs before the pair is built. For each n, ask n % 3 == 0? (i.e. does n
divide evenly by 3, leaving remainder 0?) If false, that n produces no entry at all .
Why this step? A trailing if is a gate , not a value-chooser — it decides whether an item
enters, not what it becomes.
Survivors: 3, 6, 9.
Why this step? These are the only n in 1..10 with remainder 0 mod 3.
Build pairs for survivors only: 3:9, 6:36, 9:81 (using n**2, the square).
Why this step? The head is evaluated only for items that passed the gate.
Result: {3: 9, 6: 36, 9: 81}.
Verify: 3**2==9, 6**2==36, 9**2==81, length 3. ✅
Statement. Label each number 0..4 as "even" or "odd".
{n: ( "even" if n % 2 == 0 else "odd" ) for n in range ( 5 )}
Forecast: how many entries this time — same as, or fewer than, the input?
No trailing if, so nothing is filtered out. Every one of the 5 items produces an entry.
Why this step? This is the key contrast with Example 4: a filter removes items; a ternary
keeps all of them and only changes the value . (See Ternary conditional expression .)
Evaluate the ternary per item. n=0→"even", 1→"odd", 2→"even", 3→"odd", 4→"even"
(using n % 2 == 0 = "remainder 0 when divided by 2" = even).
Why this step? The ternary A if cond else B picks a value; it lives inside the value
expression, at the front, wrapped in parentheses for clarity.
Assemble. All 5 keys 0..4 are distinct — no collisions.
Why this step? Confirms the entry count equals the input count (5), unlike the filtered case.
Result: {0: 'even', 1: 'odd', 2: 'even', 3: 'odd', 4: 'even'} — 5 entries.
Verify: exactly 3 keys map to "even" (the keys 0,2,4) and 2 map to "odd". ✅
Common mistake Filter vs chooser — the classic mix-up
{... for n in ... if cond} drops items (gate at the end).
{... : (A if cond else B) for n in ...} keeps all items (chooser in front).
Ask: "do I want fewer items, or the same items with different values?"
Statement. Collect the distinct string lengths from a list of words into a set .
words = [ "hi" , "cat" , "dog" , "sun" , "elephant" ]
{ len (w) for w in words}
Forecast: the list has 5 words — how many elements in the resulting set?
No colon ⇒ this is a set (see Sets in Python ). Each item yields one value, not a pair.
Set elements, like dict keys, must be hashable (numbers qualify).
Why this step? Recognising set-vs-dict is step zero; the absence of : decides it. (The input
words is a list; the output is a set — don't confuse the two.)
Compute each length: "hi"→2, "cat"→3, "dog"→3, "sun"→3, "elephant"→8.
Why this step? We need the raw values before dedup to see what collides .
Drop duplicates. The value 3 was produced three times; a set keeps it once .
Why this step? This is the entire reason to pick a set here — automatic uniqueness, powered by
hashing .
Result: {2, 3, 8} — 3 elements from 5 words.
Verify: set has length 3 and equals {2, 3, 8}. ✅
Statement. Predict each:
{k: v for k, v in []} # (a) empty source
{x for x in []} # (b) empty set comp
type ({}) # (c) the empty-braces trap
type ( set ()) # (d) how to get a real empty set
Forecast: which of (a)–(d) is a dict and which is a set?
(a) Empty iterable → empty result of the comprehension's type. No items means no pairs,
but the shape is still a dict comprehension (it has a colon), so you get {} — an empty dict.
Why this step? The type is fixed by syntax , not by whether any item ran.
(b) Empty set comprehension → an empty set , printed as set().
Why this step? No colon ⇒ set, even with zero items.
(c) {} alone is an empty dict. Braces belonged to dicts first, historically.
Why this step? This is the single most common beginner trap — braces look set-ish.
(d) set() is the only way to write an empty set literal.
Why this step? There is no colon-free brace form for "empty set", so the constructor is required.
Results: (a) {} dict, (b) set(), (c) dict, (d) set.
Verify: type({}) is dict and type(set()) is set and the empty comprehension in (a) has length 0. ✅
Statement. Build a multiplication lookup: for i in 1..3 and j in 1..3, map the tuple
(i, j) to the product i*j.
{(i, j): i * j for i in range ( 1 , 4 ) for j in range ( 1 , 4 )}
Forecast: how many keys — and why is the key a tuple ?
Read the two fors left-to-right = outer then inner. The first for i is outer; for j
is inner. This sweeps the full 3×3 grid.
Why this step? Nested fors in a comprehension run like nested loops — outer slow, inner fast.
Count the cells. 3 values of i × 3 values of j = 9 pairs, all distinct.
Why this step? No collision because each (i, j) combination is unique.
Why a tuple key? A (i, j) pair is hashable (it's immutable — its contents can never
change), so it can be a dict key; a list [i, j] could not, because lists are mutable and thus
unhashable (this foreshadows Example 9).
Why this step? Keys must be hashable — tuples qualify, lists don't. (See the "Hashable" definition above.)
Result: 9 entries, e.g. {(1,1):1, (1,2):2, ..., (3,3):9}; value at (2, 3) is 6.
Verify: length is 9 and the value at key (2, 3) equals 6. ✅
Look at Figure s03 below: the 3×3 grid draws every (i, j) combination as a dot, with the tiny
product i*j written beside it — i climbs the vertical axis (outer loop), j runs along the
horizontal (inner loop). The single red dot marks key (2, 3), whose stored value is 6, so
you can see that nested fors enumerate a rectangle of keys.
Statement. Why does the left crash and the right succeed?
{[i] : i for i in range ( 3 )} # ✗ TypeError: unhashable type: 'list'
{(i,): i for i in range ( 3 )} # ✓ works
Forecast: what single word describes what a valid key must be?
A dict stores keys by their hash. To place a key, Python calls hash(key); lists refuse
to be hashed because they can change (recall the "Hashable" definition — mutable things can't hash).
Why this step? The crash isn't about comprehensions at all — it's the underlying dict rule.
[i] is a list ⇒ unhashable ⇒ TypeError the moment the first pair is built.
Why this step? Even one bad key aborts the whole comprehension.
(i,) is a one-element tuple ⇒ immutable ⇒ hashable ⇒ fine.
Why this step? The trailing comma makes it a tuple, not a parenthesised value; immutability is
what lets it hash. (See Hashing and hashable types .)
Result: left raises TypeError; right yields {(0,): 0, (1,): 1, (2,): 2}.
Verify: the tuple-key version has length 3 and value 2 at key (2,); a list is genuinely
not hashable. ✅
"If it can change, it can't be a key." Lists, sets, dicts change → no. Numbers, strings,
tuples-of-those don't → yes.
Statement (word problem). A shop's inventory is stock = {"pen": 0, "ink": 12, "pad": 3, "cap": 0}.
Build a set of the names of products that are out of stock , then a dict of only the in-stock
products mapping name → quantity.
stock = { "pen" : 0 , "ink" : 12 , "pad" : 3 , "cap" : 0 }
out_of_stock = {name for name, qty in stock.items() if qty == 0 }
in_stock = {name: qty for name, qty in stock.items() if qty > 0 }
Forecast: which names land in each collection?
Translate the intent. "Names that are out of stock" → we only need names , no pairing →
a set with a filter qty == 0.
Why this step? Choosing set vs dict flows from what the question asks you to keep .
.items() gives (name, qty); unpack and gate. out_of_stock keeps "pen", "cap".
Why this step? The filter runs before the value name is emitted — only zero-quantity names pass.
In-stock needs name→qty , so it's a dict with filter qty > 0: {"ink": 12, "pad": 3}.
Why this step? Here we do want to remember the quantity, so a pair (colon!) is required.
Thanks to insertion order, "ink" prints before "pad" (their order in the original stock).
Exam twist. "How would you turn out_of_stock into {name: 0 for ...} instead?" — swap the
set (no colon) for a dict with a constant value 0:
{name: 0 for name, qty in stock.items() if qty == 0 }
# {'pen': 0, 'cap': 0}
The lesson: the colon is the only edit needed to move from "a set of names" to "a dict of names → 0".
Results: out_of_stock == {"pen", "cap"}; in_stock == {"ink": 12, "pad": 3}; the twist gives
{"pen": 0, "cap": 0}.
Verify: out_of_stock has 2 elements; in_stock has 2 entries and in_stock["ink"] == 12; the
twisted dict equals {"pen": 0, "cap": 0}. ✅
Statement (exam twist). Keep the numbers 1..30 that are both even and multiples of 3,
mapping each to its half.
{n: n // 2 for n in range ( 1 , 31 ) if n % 2 == 0 if n % 3 == 0 }
Forecast: two ifs in a row — is that "or" or "and"? Which keys survive?
Two ifs written back-to-back mean AND, not OR. An item must pass n % 2 == 0 and then
n % 3 == 0; failing either drops it. It reads like stacked gates in a pipe.
Why this step? Beginners often assume a second if widens the selection; it actually narrows
it — each filter can only remove more items.
First gate (even): 2, 4, 6, …, 30. Second gate (mult of 3): of those, keep 6, 12, 18, 24, 30.
Why this step? Being even and a multiple of 3 is the same as being a multiple of 6, so the
survivors are exactly the multiples of 6 up to 30.
n // 2 is integer division — it halves and throws away any fractional part. Halves: 6→3,
12→6, 18→9, 24→12, 30→15.
Why this step? All survivors are even, so // 2 is exact here; the head builds key → half.
Result: {6: 3, 12: 6, 18: 9, 24: 12, 30: 15} — 5 entries.
Verify: all 5 keys are multiples of 6, length is 5, and 30 // 2 == 15. ✅
ifs = AND, and one filter can never add items
... for n in ... if A if B keeps only items where both A and B are true. If you actually
wanted "A or B", you need a single combined condition if A or B, not two separate ifs.
Recall Active recall — cover the answers
How many entries does {x % 3: x for x in range(6)} produce? ::: 3 — keys 0,1,2, each with its last value 3,4,5
In what order do those keys print, and why? ::: 0, 1, 2 — their first-insertion order (dicts preserve insertion order since Python 3.7)
How do you keep all colliding items instead of overwriting? ::: Accumulate into list values (e.g. defaultdict(list)), then snapshot into a dict
A trailing if vs a value-ternary — which drops items? ::: The trailing if drops items; the ternary keeps all and only changes values
{len(w) for w in ["hi","cat","dog"]} gives what? ::: {2, 3} — the duplicate length 3 collapses
What does {k: v for k, v in []} return, and of what type? ::: {}, an empty dict (syntax has a colon)
Why does {[i]: i for i in range(3)} crash? ::: A list is unhashable (mutable), so it cannot be a dict key
What do two back-to-back ifs in one comprehension mean? ::: AND — an item must pass both conditions
To turn a set-of-names into a dict-of-names→0, what single edit is needed? ::: Add a colon and a value: {name: 0 for ...}
What does ** mean in Python? ::: The power (exponent) operator — n**2 is n 2 , n**3 is n 3
Parent: Dictionary and set comprehensions — the core rules this page stress-tests
List comprehensions — same syntax family with []
Dictionaries in Python — the collision / last-write-wins behaviour and insertion order
Sets in Python — automatic uniqueness in Example 6
Generator expressions — lazy (...) cousin, no collision handling since nothing is stored
Hashing and hashable types — why Example 9 crashes and what "hashable" means
Ternary conditional expression — the value-chooser in Example 5