Comprehensions
The Pythonic way to build a list, dict, set, or generator from another iterable — and the honest line between when they help readability and when they hurt it.
What you'll learn
- List, dict, set, and generator comprehensions — one skeleton, four containers
- How a comprehension maps onto the loop it replaces
- When a comprehension is clearer than a loop, and when it is not
- Why a generator expression saves memory on huge iterables
Before you start
A comprehension is Python’s shorthand for one very common task: make a new collection from an existing iterable, transforming and filtering along the way. At its best, a comprehension reads straight across like a sentence and replaces four lines of loop with one clear line. At its worst, it folds three loops and two conditions into a single unreadable knot. The whole skill is telling those two apart — so let us build the good intuition first, then draw the line.
Start from the loop it replaces
Every comprehension is a compressed loop, and the fastest way to read one is to see the loop underneath it:
records = [
{"id": 1, "email": "alice@x.com"},
{"id": 2, "email": None},
{"id": 3, "email": "Chen@X.COM"},
{"id": 4, "email": "dani@x.com"},
{"id": 5, "email": ""},
]
# The loop — explicit, and perfectly fine.
emails = []
for r in records:
if r["email"]:
emails.append(r["email"].lower())
print(emails)
# The comprehension — exactly the same result.
emails_v2 = [r["email"].lower() for r in records if r["email"]]
print(emails_v2)
['alice@x.com', 'chen@x.com', 'dani@x.com']
['alice@x.com', 'chen@x.com', 'dani@x.com']
Read the comprehension aloud: “the lower-cased email, for each record, where the email is truthy.” The two None/"" rows fell out because they are falsy. And the three pieces of that one line map exactly onto the three pieces of the loop — the colours below make the correspondence visible:
EXPR becomes the append, the for-clause stays the for, the if-clause stays the if.The four flavours
The same skeleton builds four different containers — change the brackets, and in one case add a colon:
nums = [1, 2, 3, 4, 5]
# List — square brackets.
print([n * n for n in nums])
# Set — curly braces; dedupes automatically (sorted here only to show stably).
words = ["python", "Python", "PYTHON", "data"]
print(sorted({w.lower() for w in words}))
# Dict — curly braces with key: value.
print({n: n * n for n in nums})
# Generator — parentheses; lazy, computes on demand rather than all at once.
gen = (n * n for n in nums)
print(type(gen).__name__) # nothing has been computed yet
print(list(gen)) # materialise only when asked
[1, 4, 9, 16, 25]
['data', 'python']
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
generator
[1, 4, 9, 16, 25]
The whole family in one phrase: square brackets give a list, curly braces a set, curly braces with a colon a dict, and parentheses a generator. The for-clause and the if-clause behave identically across all four. The set flavour quietly de-duplicated three spellings of “python” down to one; the generator computed nothing until list(gen) pulled the values out.
When a comprehension hurts
Because comprehensions feel clever, the temptation is to push them too far. The honest rule: if you have to read it twice, write the loop.
data = [
{"id": i, "name": f"user{i}", "scores": [i, i * 2, i * 3]}
for i in range(1, 6)
]
# Hard to read — two for-clauses and a compound filter.
result = [
s for d in data
for s in d["scores"]
if s > 5 and d["id"] % 2 == 0
]
print(result)
# Easier to read, and far easier to debug — the same logic as a loop.
result2 = []
for d in data:
if d["id"] % 2 != 0:
continue
for s in d["scores"]:
if s > 5:
result2.append(s)
print(result2)
[6, 8, 12]
[6, 8, 12]
Both produce [6, 8, 12], but only one of them you could set a breakpoint inside, or explain to a teammate in a sentence. When a comprehension carries two loops and a guard, the loop is not a step backwards — it is the more honest code.
Nested comprehensions — the legitimate case
There is one place a nested comprehension genuinely beats a loop: transforming nested data, like flattening or transposing a grid.
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
# Flatten — for each row, for each value in that row.
flat = [val for row in matrix for val in row]
print(flat)
# Transpose — outer picks a column index, inner pulls that column from each row.
transposed = [[row[i] for row in matrix] for i in range(len(matrix[0]))]
for r in transposed:
print(r)
[1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 4, 7]
[2, 5, 8]
[3, 6, 9]
The trick for reading these is that the for clauses run left-to-right in exactly the order you would nest the loops: in the flatten, “val, for each row, for each val in that row.”
Generator expressions — saving memory
A list comprehension builds the whole list in memory at once. A generator expression — same syntax, round brackets — yields one value at a time and stores none of them, which is the difference between fitting in RAM and not when the source is huge:
# A list comprehension actually STORES all one million squares.
list_version = [n * n for n in range(1_000_000)]
print(len(list_version)) # a million integers held in memory
# A generator expression holds NONE of them — it computes on demand.
gen_version = (n * n for n in range(1_000_000))
print(type(gen_version).__name__)
# Built-ins consume a generator directly — no intermediate list is ever built.
total = sum(n * n for n in range(1_000_000))
print(total)
# Stop at the first match without computing the rest of the range.
first_big = next(n for n in range(1, 10_000_000) if n * n > 1_000_000)
print(f"first n where n*n > 1M: {first_big}")
1000000
generator
333332833333500000
first n where n*n > 1M: 1001
Two things to notice. sum(n * n for n in range(1_000_000)) produced the full total 333332833333500000 while never building a million-element list — the generator fed sum one square at a time. And next(...) stopped the moment it found 1001 (the first integer whose square exceeds a million), without touching the remaining ten million candidates. Built-ins that accept an iterable — sum, min, max, any, all, next, "".join — all take a generator expression directly, with no list(...) wrapper. In a pipeline over millions of rows, that is precisely the line between “streams comfortably” and “runs out of memory.”
A small note on speed
Comprehensions also tend to run a little faster than the equivalent loop, because the appending happens in C inside the interpreter rather than in Python bytecode. Treat that as a pleasant side effect, not a reason to choose them — readability comes first — though it is real in tight inner loops:
# Same result; the comprehension does its appending in C.
xs = [n * n for n in range(10_000)]
# The hand-written loop appends in Python on every iteration.
xs = []
for n in range(10_000):
xs.append(n * n)
A few patterns you will reuse
# Build a lookup dict from a list of records.
users = [
{"id": 1, "email": "alice@x.com"},
{"id": 2, "email": "bob@x.com"},
{"id": 3, "email": "chen@x.com"},
]
by_id = {u["id"]: u["email"] for u in users}
print(by_id[2])
# Normalise and dedupe into a set (sorted here only to show stably).
raw_tags = ["Python", "PYTHON", " python ", "Data", "data"]
print(sorted({t.strip().lower() for t in raw_tags}))
# Drop None before averaging a column.
prices = [10.0, None, 25.5, None, 7.25, 99.0]
valid = [p for p in prices if p is not None]
print(f"avg: {sum(valid) / len(valid):.2f}")
# Turn header lines into a dict.
header_lines = ["Content-Type: application/json", "X-Request-Id: abc123"]
headers = {line.split(": ")[0].lower(): line.split(": ")[1] for line in header_lines}
print(headers)
bob@x.com
['data', 'python']
avg: 35.50
{'content-type': 'application/json', 'x-request-id': 'abc123'}
In one breath
- A comprehension is a loop compressed: output expression,
forclause, optionalifclause. - Brackets pick the container —
[]list,{}set,{k: v}dict,()generator. - If you must read it twice, write the loop; nested comprehensions earn their place only for flatten/transpose-style work.
- A generator expression streams values lazily and stores none — feed it straight to
sum,any,next,"".join. - Sets and dicts built this way are subject to the same ordering rules as any set or dict.
Practice
Quick check
What’s next
That completes the foundation — syntax, types, collections, control flow, functions, and comprehensions. The next block moves into what makes Python feel Pythonic: iterators, generators, decorators, and context managers.
Practice this in an interview
All questionsA dict comprehension uses the syntax `{key_expr: value_expr for item in iterable if condition}` to build a dictionary in one expression. It is faster than calling `dict()` on a list of pairs and more readable than a for-loop with repeated `d[k] = v` assignments. Common data patterns include inverting a dict, grouping values, and transforming keys or values in bulk.
List comprehensions are typically 20–50% faster than equivalent for-loops with list.append() because the bytecode is optimised and the attribute lookup for append is avoided. Generator expressions use O(1) memory versus O(n) for a comprehension, so prefer them when you only iterate once.
Comprehensions are syntactic sugar for building a new collection by iterating over an iterable and optionally filtering elements. They are faster than equivalent for-loops because the iteration runs at the C level inside the interpreter. Avoid them when the expression is too complex to read at a glance — a plain loop with descriptive variable names is preferable.
A list materialises all values in memory at once; a generator produces values one at a time on demand, using O(1) memory regardless of the sequence length. Prefer generators for large or infinite sequences, pipelines, and any situation where you do not need random access.