datarekha

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.

9 min read Beginner Python Lesson 12 of 41

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:

ONE COMPREHENSION[ EXPR for ITEM in ITER if COND ]THE SAME LOOPfor ITEM in ITER:if COND:result.append(EXPR)
Same three parts, same colours: the output 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, for clause, optional if clause.
  • 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

0/3
Q1What does `[x * 2 for x in [1, 2, 3, 4] if x % 2 == 0]` produce?
Q2Which is the right way to build a {id: name} dict from a list of records?
Q3When should you pick a generator expression `(x for x in xs)` over a list comp `[x for x in xs]`?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How do you use dict comprehensions effectively, and what are common patterns in data work?

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

Are list comprehensions faster than equivalent for-loops in Python, and when should you prefer a generator expression instead?

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.

How do list, dict, and set comprehensions work in Python, and when should you avoid them?

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.

What is the difference between a generator and a list, and when should you prefer a generator?

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.

Related lessons

Explore further

Skip to content