Comprehensions, Lambdas & the Iterator Protocol¶
What this lesson gives you
Build expressive, performant data transformations using Python's comprehension syntax, understand what makes something iterable, and know exactly when a lambda is the right tool – and when it isn't.
Estimated time: 30 min read · Part: Functions & the Functional Toolkit
Python's comprehension syntax is beloved precisely because it collapses a three-line for-loop-with-append into a single readable expression. But comprehensions are more than syntax sugar – they each create their own scope, run slightly faster than equivalent loops, and when swapped for a generator expression, switch from eager to lazy evaluation. To use them well, you need to understand the iterator protocol they rely on, the walrus operator that unlocks stateful comprehensions, and the itertools module that extends the pattern to combinations, groupings, and infinite streams.
List, Set, and Dict Comprehensions¶
sales = [
{"product": "Widget", "revenue": 120, "region": "APAC"},
{"product": "Gadget", "revenue": 450, "region": "EMEA"},
{"product": "Doohickey", "revenue": 90, "region": "APAC"},
{"product": "Thingamajig", "revenue": 310, "region": "EMEA"},
]
# List comprehension: transform
revenues = [row["revenue"] for row in sales]
print(revenues) # [120, 450, 90, 310]
# Filter: only high-value
big_deals = [row["product"] for row in sales if row["revenue"] > 200]
print(big_deals) # ['Gadget', 'Thingamajig']
# Dict comprehension: product → revenue map
rev_map = {row["product"]: row["revenue"] for row in sales}
print(rev_map) # {'Widget': 120, 'Gadget': 450, ...}
# Set comprehension: unique regions
regions = {row["region"] for row in sales}
print(regions) # {'APAC', 'EMEA'} (order varies)
# Generator expression (lazy – parens, not brackets)
total = sum(row["revenue"] for row in sales)
print(total) # 970
Nested Comprehensions¶
# Flatten a 2-D matrix
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [cell for row in matrix for cell in row]
print(flat) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Transpose (rows become columns)
transposed = [[row[i] for row in matrix] for i in range(3)]
print(transposed) # [[1, 4, 7], [2, 5, 8], [3, 6, 9]]
# Cartesian product (pairs of sizes and colours)
sizes = ["S", "M", "L"]
colours = ["red", "blue"]
skus = [f"{s}-{c}" for s in sizes for c in colours]
print(skus)
# ['S-red', 'S-blue', 'M-red', 'M-blue', 'L-red', 'L-blue']
When NOT to comprehend
Avoid comprehensions when: (1) the loop body has side effects like printing or writing to a file – use an explicit loop so the intent is clear; (2) there are more than two nested for clauses – readability collapses; (3) the filtering logic is complex multi-line logic – extract a named helper function instead. Comprehensions communicate transformation; loops communicate action.
The Walrus Operator := in Comprehensions¶
import re
log_lines = [
"2024-01-15 ERROR disk full",
"2024-01-15 INFO backup started",
"2024-01-15 ERROR connection timeout",
"2024-01-15 DEBUG heartbeat",
]
# Without walrus: regex match twice (once to filter, once to extract)
errors_bad = [re.search(r"ERROR (.+)", line).group(1)
for line in log_lines
if re.search(r"ERROR (.+)", line)] # double work
# With walrus: compute once, reuse in same expression
errors_good = [m.group(1)
for line in log_lines
if (m := re.search(r"ERROR (.+)", line))]
print(errors_good) # ['disk full', 'connection timeout']
# Walrus also works for side-effectful computations
import random
random.seed(42)
results = [y for x in range(5) if (y := random.randint(1, 10)) > 5]
print(results) # [7, 10, 8] (values already computed, not recomputed)
The Iterator Protocol: iter() and next()¶
Anything you can for-loop over is an iterable. Python calls iter(obj) on it, which returns an iterator – an object with a __next__() method that returns successive values and raises StopIteration when exhausted.
# What Python's for-loop actually does:
lst = [10, 20, 30]
it = iter(lst) # calls lst.__iter__()
print(next(it)) # 10 (calls it.__next__())
print(next(it)) # 20
print(next(it)) # 30
# print(next(it)) # → StopIteration
# Writing a custom iterator
class CountUp:
"""Yields integers from start up to stop (inclusive)."""
def __init__(self, start, stop):
self.current = start
self.stop = stop
def __iter__(self):
return self # iterator is its own iterable
def __next__(self):
if self.current > self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
for n in CountUp(1, 5):
print(n, end=" ") # 1 2 3 4 5
sequenceDiagram
participant Loop as for loop
participant Iterable
participant Iterator
Loop->>Iterable: iter(obj) / __iter__()
Iterable-->>Loop: iterator object
loop until StopIteration
Loop->>Iterator: next(it) / __next__()
Iterator-->>Loop: value
end
Iterator-->>Loop: StopIteration raised Essential itertools¶
import itertools
# product – Cartesian product (like nested for-loops)
for a, b in itertools.product("AB", [1, 2]):
print(f"({a},{b})", end=" ") # (A,1) (A,2) (B,1) (B,2)
print()
# combinations / permutations
print(list(itertools.combinations("ABCD", 2)))
# [('A','B'),('A','C'),('A','D'),('B','C'),('B','D'),('C','D')]
# groupby – consecutive groups (data must be pre-sorted by key)
events = [("APAC","login"),("APAC","purchase"),("EMEA","login"),("EMEA","view")]
for region, group in itertools.groupby(events, key=lambda e: e[0]):
actions = [g[1] for g in group]
print(f"{region}: {actions}")
# APAC: ['login', 'purchase']
# EMEA: ['login', 'view']
# takewhile / dropwhile
nums = [2, 4, 6, 7, 8, 10]
print(list(itertools.takewhile(lambda n: n % 2 == 0, nums))) # [2, 4, 6]
print(list(itertools.dropwhile(lambda n: n % 2 == 0, nums))) # [7, 8, 10]
# chain – concatenate iterables without building a list
a = range(3)
b = range(5, 8)
print(list(itertools.chain(a, b))) # [0, 1, 2, 5, 6, 7]
# islice – lazy slicing of any iterator
inf_squares = (n*n for n in itertools.count(1))
print(list(itertools.islice(inf_squares, 6))) # [1, 4, 9, 16, 25, 36]
Lambda – Anonymous Functions¶
from functools import reduce
# Good use: short, single-expression key function
records = [("Alice", 32), ("Bob", 25), ("Carol", 28)]
records.sort(key=lambda r: r[1]) # sort by age
print(records) # [('Bob', 25), ('Carol', 28), ('Alice', 32)]
# Good use: inline with map/filter/reduce
prices = [9.99, 24.50, 3.75, 149.00]
discounted = list(map(lambda p: round(p * 0.9, 2), prices))
print(discounted) # [8.99, 22.05, 3.38, 134.1]
total = reduce(lambda acc, p: acc + p, prices, 0)
print(f"Total: ${total:.2f}") # Total: $187.24
# ❌ Anti-pattern: assigning lambda to a name
# bad: double = lambda x: x * 2
# good: def double(x): return x * 2
# Named lambdas lose traceback clarity and can't have docstrings.
zip_longest for unequal sequences
The built-in zip() stops at the shortest sequence. itertools.zip_longest(*iterables, fillvalue=None) pads shorter sequences with fillvalue so you get a row for every element of the longest. Essential when merging time-series with different cadences.
| Approach | Eager? | Relative speed | Best for |
|---|---|---|---|
| for-loop + append | Yes | Baseline | Complex logic, side effects |
| List comprehension | Yes | ~1.2–1.5× faster | Transformations, filters |
| map() / filter() | Lazy (Python 3) | Comparable to comprehension | Functional pipelines, large data |
| Generator expression | No (lazy) | Fastest for streaming | sum(), any(), all(), large sequences |
| itertools chains | No (lazy) | Near zero overhead | Combining, slicing, grouping iterators |
Story – The ETL that blew up RAM
A retail analytics team loaded 8 million order rows into a list with a list comprehension to compute a running total. The process consumed 6 GB of RAM and was killed nightly by OOM killer. Replacing the list comprehension with a generator expression and wrapping the pipeline in itertools.chain to read three CSV shards brought peak memory to 40 MB – a 150× reduction – with no change in output. The fix was a one-character change: [ → (.
Analogy – Iterable vs Iterator is a recipe vs a cook
A list is a recipe: you can hand it to multiple cooks (create multiple iterators from it) and each starts from step 1. An iterator is the cook who's actively working through the recipe – she remembers her place. When she's done, she's done; you can't rewind her. You need a fresh cook (call iter() again) to restart.
Generator expressions are iterators in disguise
(x*x for x in range(10)) doesn't compute anything yet – it creates an iterator object. Each call to next() on it runs the loop body one step. That's why you can pass a generator expression directly to sum() without building the intermediate list: sum(x*x for x in range(10)) streams values one-by-one into the accumulator.
Consulting lens – Comprehension as specification
When scoping a data transformation for a client, write the business rule first as a comprehension: [row for row in pipeline if row["status"] == "active" and row["tier"] in eligible_tiers]. This is executable pseudocode that non-engineers can read and validate before a single database query is written. It also naturally drives schema discussions: "What is eligible_tiers? Is it a lookup table or hard-coded?"
Knowledge check
InterviewWhat does list(zip([1,2,3], [4,5])) return?
- [(1,4),(2,5),(3,None)]
- [(1,4),(2,5)]
- [(1,4),(2,5),(3,)]
- Raises ValueError
Answer
[(1,4),(2,5)]
zip() stops at the shortest iterable, so only two pairs are produced. To include the leftover 3 , use itertools.zip_longest([1,2,3],[4,5], fillvalue=None) which yields [(1,4),(2,5),(3,None)] .
Exercise
Exercise 4.2 · Pipeline with itertools Given a list of (timestamp, event_type, user_id) tuples, use a combination of comprehensions and itertools to: (1) filter to only "purchase" events, (2) group by user_id, (3) count purchases per user, (4) return only users with 2+ purchases. Use itertools.groupby (after sorting) and a dict comprehension for the count step.
Show solution
import itertools
events = [
("2024-01-01", "view", "u1"),
("2024-01-02", "purchase", "u1"),
("2024-01-02", "purchase", "u2"),
("2024-01-03", "purchase", "u1"),
("2024-01-03", "view", "u3"),
("2024-01-04", "purchase", "u2"),
("2024-01-04", "purchase", "u3"),
("2024-01-05", "purchase", "u2"),
]
# Step 1: filter to purchases, sort by user_id for groupby
purchases = sorted(
[(ts, uid) for ts, etype, uid in events if etype == "purchase"],
key=lambda x: x[1]
)
# Step 2: group and count
counts = {
uid: sum(1 for _ in grp)
for uid, grp in itertools.groupby(purchases, key=lambda x: x[1])
}
# Step 3: filter to repeat buyers
repeat_buyers = {uid: cnt for uid, cnt in counts.items() if cnt >= 2}
print(repeat_buyers) # {'u1': 2, 'u2': 3}
Key takeaways
- List comprehensions are eager; generator expressions are lazy – prefer generators when passing to aggregation functions like
sum()orany(). - The walrus operator
:=lets you compute-once-use-twice inside a comprehension, avoiding redundant work. - The iterator protocol is just two methods:
__iter__returns self,__next__returns the next value or raisesStopIteration. itertools.groupbyrequires sorted input; it groups consecutive equal keys, not all equal keys globally.- Use
itertools.chainto concatenate iterables without materialising them into lists. - Lambda is appropriate as an inline key function; assign to a name only with
deffor clarity and tracebacks. - When a comprehension has more than two
forclauses or complex conditions, extract a named helper function.