Skip to content

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

comprehensions.py
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

nested_comp.py
# 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

walrus.py
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.

iterator_protocol.py
# 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

itertools_tour.py
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

lambda_usage.py
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
exercise_4_2.py
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() or any().
  • 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 raises StopIteration.
  • itertools.groupby requires sorted input; it groups consecutive equal keys, not all equal keys globally.
  • Use itertools.chain to concatenate iterables without materialising them into lists.
  • Lambda is appropriate as an inline key function; assign to a name only with def for clarity and tracebacks.
  • When a comprehension has more than two for clauses or complex conditions, extract a named helper function.