Skip to content

Lambdas and comprehensions

docs.scrimba.com

These three features have something in common: they let you express ideas that would otherwise take several lines in a single, readable expression. Used well, they make code shorter and clearer. Used badly, they make it unreadable. This chapter covers when to reach for each one and when to stop.

Lambdas, comprehensions, and zip are three tools that compress common patterns into expressions. They are not required, but they appear throughout Python code and are worth recognising and writing fluently. The guiding principle: use them when they make intent clearer, not only shorter.

These tools all express a transformation as an expression (a piece of code that evaluates to a value) instead of an imperative loop that builds a result step by step. Lambdas give you a small unnamed function inline. Comprehensions build a collection in one pass. Generators are lazy (they produce values on demand rather than all at once), so they hold one item in memory instead of the whole sequence. zip walks several sequences in step. The payoff is code that reads as "what I want" rather than "how to assemble it", as long as you stop before the one-liner stops being readable.

Lambda functions

A lambda is a nameless, one-expression function. You create it with the lambda keyword. Its real usefulness is that you can write it inline, right where you need it, without defining a named function first. This is what makes it useful with sorted().

A lambda is an anonymous single-expression function. It can take multiple arguments but its body must be a single expression, not a statement. Its primary use is as an inline key= or callback argument where a full def would add unnecessary indirection. For anything more complex, use def.

A lambda is the same kind of function object def produces, with three limits: it has no name (it shows as <lambda> in a traceback, the error report Python prints when something raises), its body is one expression so it can't hold statements, and it carries no docstring. The pitfall that costs real debugging time is late binding in a closure (a function that captures a variable from the scope around it). A lambda built inside a loop captures the loop variable by reference, so every lambda sees the variable's final value, not the value it had when the lambda was created:

python
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])        # [2, 2, 2], not [0, 1, 2]

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])        # [0, 1, 2]: i=i binds the value now

The i=i trick pins the current value as a default argument at creation time. Reach for it any time you build callables in a loop.

python
double = lambda x: x * 2
double(5)   # 10

That is equivalent to:

python
def double(x):
    return x * 2

For most cases, use def. Lambdas have one real advantage: you can write them inline, right where you need them, without naming them. This is what makes them useful with sorted(), map(), and filter():

python
players = [("Alice", 87), ("Bob", 74), ("Carol", 92)]

sorted(players, key=lambda p: p[1])              # sort by score (ascending)
sorted(players, key=lambda p: p[1], reverse=True)  # sort by score (descending)

Without a lambda, you would have to define a named function only for the key= argument. The lambda keeps the intent local and visible.

Lambdas can take multiple arguments:

python
add = lambda a, b: a + b
add(3, 4)   # 7

When to use a lambda: only when it is a small expression used in one place. If it is growing, or you need to reuse it, write a proper def. A lambda that spans several operators or needs a conditional is usually a sign to switch to def.

JunoLambda functions A lambda is a tiny one-line function with no name, written with the lambda keyword. Its whole reason to exist is going inline as a key= for sorted() so you don't define a separate function for one job. The moment it gets longer than one neat expression, I write a real def and feel better for it.
JunoLambda functions A lambda is an anonymous single-expression function, handy as an inline key= or callback where a named def would only add noise. The body is one expression, no statements allowed. Anything more involved, reach for def.
JunoLambda functions Same function object as def, minus a name, statements, and a docstring, so keep them to a one-liner key=. The trap is late binding: a lambda built in a loop reads the loop variable's final value, so write lambda i=i: i to pin it. That one bites everyone exactly once.

List comprehensions

The most common transformation in Python: take a sequence, do something to each item, get a new list back. A list comprehension does this in one readable line: [expression for item in iterable]. You can also add a filter with if.

List comprehensions are a concise replacement for the build-with-a-loop pattern. They are generally faster than the equivalent for loop with .append(), since Python does not call a method on each pass. The structure is [expression for item in iterable if condition]; the if clause is optional.

A comprehension is meaningfully faster than the same loop calling .append() each pass, so it is the idiomatic way to build a list from a transformation. It also runs in its own scope (the region where a name is visible), which means the loop variable does not leak into the surrounding code: after [n ** 2 for n in numbers], the name n does not exist outside the comprehension. The trap to watch is reaching for one where the body does real work. Once the expression grows past a clean transform, or you would want to add logging or a try block, the explicit loop is the better tool, because a comprehension can only hold an expression, never a statement.

The long way:

python
numbers = [1, 2, 3, 4, 5]
squares = []
for n in numbers:
    squares.append(n ** 2)

The list comprehension:

python
squares = [n ** 2 for n in numbers]

The structure is always the same: [expression for item in iterable].

python
scores = [87, 42, 96, 55, 71]
scaled = [s * 1.1 for s in scores]          # apply a 10% bonus
as_grades = [f"{s}/100" for s in scores]    # format each one
JunoList comprehensions[expression for item in iterable] takes a sequence, does one thing to each item, and hands you back a new list. Read it left to right and it says exactly what it does. This was the first Python feature that made me feel like I was writing Python rather than translating from another language.
JunoList comprehensions[expression for item in iterable] replaces the build-with-a-loop-and-.append() pattern, and reads cleaner doing it. It produces a new list and mutates nothing. Keep the expression simple, or the readability you gained is gone.
JunoList comprehensions Faster than the .append() loop and it runs in its own scope, so the loop variable never leaks out. The line to hold: a comprehension takes an expression, not a statement, so the second you want a try block or a log line, the explicit loop wins.

Filtering with a condition

Add an if clause to include only items that pass a test. The result is a new list with only the items where the condition is True.

The if clause in a comprehension is a filter, not an if/else. It runs once per item and includes only items for which the condition is truthy. For a conditional transform (map one value to another based on a condition), use a ternary expression inside the main expression.

The position of the if decides whether you filter or transform, and mixing them up is a common cause of a wrong-length result. An if at the end filters: [x for x in data if x > 0] drops items. A conditional at the front maps: [x if x > 0 else 0 for x in data] keeps every item and rewrites the failing ones (here, clamping negatives to zero). You can combine both in one comprehension, [x * 2 for x in data if x > 0], and stack multiple trailing if clauses, which chain together as and.

python
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
evens = [n for n in numbers if n % 2 == 0]    # [2, 4, 6, 8]
odds = [n for n in numbers if n % 2 != 0]     # [1, 3, 5, 7]
python
scores = [87, 42, 96, 55, 71, 38]
passing = [s for s in scores if s >= 60]    # [87, 96, 71]
failing = [s for s in scores if s < 60]     # [42, 55, 38]
JunoFiltering with a condition Add if condition at the end to keep only the items that pass the test: [x for x in data if x > 0]. Anything that comes out falsy gets left out of the new list. Same comprehension you already know, with a doorman on it.
JunoFiltering with a condition A trailing if filters, keeping only items where the condition is truthy. That is different from a conditional inside the expression: [x if x > 0 else 0 for x in data] rewrites values instead of dropping them. Know which one you want before you write it.
JunoFiltering with a conditionif at the end filters and changes the length; a conditional at the front maps and keeps it. Confuse the two and you ship a result that is the wrong size. Stacked trailing if clauses chain with and.

Nested comprehensions

You can nest comprehensions to flatten a list of lists into a single list. Read it left to right: for each row, for each item in that row, include the item.

Nested comprehensions execute from left to right. The first for clause is the outer loop, the second is the inner. They produce a single flat result, not a 2D structure. If the comprehension is hard to read at a glance, write the loops explicitly.

The clause order reads in the same order as the equivalent nested loops: the first for is the outer loop, the second is the inner, and a later clause can use a name bound by an earlier one. Where this earns its keep is flattening; where it hurts is a true grid of combinations (every pair from two sequences), for which itertools.product reads far clearer than a double for. The rule that keeps a codebase sane: if parsing the comprehension takes you more than a second, the explicit loop is the better documentation, so write that instead.

python
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [item for row in matrix for item in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

Read it left to right: for each row in matrix, for each item in row, include item.

Nested comprehensions can get confusing fast. If it takes more than a moment to parse, write the loops explicitly.

JunoNested comprehensions Two for clauses in one comprehension flatten a list of lists into a single flat list: [item for row in matrix for item in row]. Read it left to right, outer loop first, exactly the order you'd write the loops. If your eyes snag on it, that's your cue to write the real loops instead.
JunoNested comprehensions Stack two for clauses and the first is the outer loop, the second the inner: [item for row in matrix for item in row] flattens a 2D list into one. The result is flat, not a grid. When it stops being readable at a glance, the explicit loops are better.
JunoNested comprehensions First for is the outer loop, and a later clause can use a name from an earlier one. Great for flattening, poor for every-pair combinations, where itertools.product reads cleaner. If parsing it takes more than a second, the loop is the better documentation.

Dict comprehensions

Dict comprehensions build a dictionary in one expression, using the same idea as list comprehensions: {key: value for item in iterable}. Add a filter with if, the same as with list comprehensions.

Dict comprehensions create a new dict from any iterable producing key-value pairs. The syntax is {key_expr: val_expr for item in iterable if condition}. Duplicate keys from the loop use the last value, silently. .items() on an existing dict is the most common source iterable for dict comprehensions.

Two things bite in real code. First, every key has to be hashable (a value Python can reduce to a fixed lookup number, which means it never changes, so strings and numbers and tuples qualify but lists and dicts do not). A key expression that yields a list raises TypeError. Second, duplicate keys do not error: if the loop produces the same key twice, the later value silently overwrites the earlier one, which is a quiet source of dropped data when you build a dict from a source that is not actually unique. When you only want to merge two existing dicts, the | operator (the dict-merge form, Python 3.9 and up) states the intent more clearly than a comprehension.

python
names = ["alice", "bob", "carol"]
scores = [87, 74, 92]

score_map = {name: score for name, score in zip(names, scores)}
# {"alice": 87, "bob": 74, "carol": 92}

With a filter:

python
passing = {name: score for name, score in score_map.items() if score >= 80}
# {"alice": 87, "carol": 92}
python
words = ["apple", "banana", "cherry"]
word_lens = {word: len(word) for word in words}
# {"apple": 5, "banana": 6, "cherry": 6}
JunoDict comprehensions{key: value for item in iterable} builds a dictionary in one line, same shape as a list comprehension with a colon between key and value. Pair it with .items() to reshape a dict you already have, or with zip() to stitch two lists into one mapping. Add an if at the end to keep only the pairs you want.
JunoDict comprehensions{key: value for item in iterable} creates a dict from any source of pairs. The two everyday sources are .items() on an existing dict and zip() over two lists. Watch for duplicate keys: the last one wins, silently.
JunoDict comprehensions Keys must be hashable (no lists), and duplicate keys don't error, the later value silently overwrites, which eats data when your source isn't unique. So check uniqueness before you trust the count. To merge two dicts, | says it plainer than a comprehension.

Set comprehensions

Set comprehensions build a set in one expression, with curly braces and no colon. Because the result is a set, duplicates are automatically removed.

Set comprehensions use {expression for item in iterable} and produce a set. They deduplicate automatically. Use them when you need a unique collection built from a transformation, where order does not matter.

The result is a set, so two things follow that the list version does not give you. Duplicate values from the expression collapse into one automatically, which is the point: it is the cleanest way to dedupe a transformation in a single expression. The cost is that a set has no order, so you cannot rely on the items coming out in any particular sequence. The members also have to be hashable, the same rule that applies to dict keys: a value Python can reduce to a fixed lookup number, which rules out lists.

python
words = ["apple", "banana", "cherry", "apple"]
unique = {w.lower() for w in words}    # {"apple", "banana", "cherry"}

Use set comprehensions when you want unique values and do not care about order.

JunoSet comprehensions{expr for item in iterable} with curly braces and no colon builds a set, and a set throws out duplicates for free. So if your job is "give me the unique ones", this does it in a line. Don't count on any particular order coming back though.
JunoSet comprehensions{expr for item in iterable} produces a set, deduplicating as it goes. Reach for it when you want a unique collection from a transform and order doesn't matter. Curly braces with no colon, that's the only thing separating it from a dict comprehension.
JunoSet comprehensions The set drops duplicates automatically, which is the whole reason to pick it over a list comprehension. The trade is no order to rely on, and members must be hashable, so no lists inside. Best fit: dedupe a transform in one expression.

Generator expressions

Generators look like list comprehensions with parentheses instead of brackets. The key difference: a list comprehension builds the entire list in memory at once. A generator produces values one at a time, only when needed. For large sequences, this uses far less memory.

A generator expression produces an iterator, not a collection. It computes values lazily: the next value is only produced when requested. This is most valuable when the result is consumed immediately by a function like sum(), max(), or any(), so there is no point building the full list first.

A generator produces values lazily (one at a time, only when something asks for the next one), so its memory stays flat no matter how large the input: it never holds more than the current value, where a list comprehension holds every element at once. That makes it the right call for a large or streaming source feeding straight into sum(), max(), or any(). The failure mode to remember is that a generator is single-use: once something iterates it to the end, it is exhausted and yields nothing more, so a second loop over the same generator runs zero times and gives no error. If you need to walk the data twice, build a list once and iterate that.

python
squares_gen = (n ** 2 for n in range(1000000))
python
total = sum(n ** 2 for n in range(1000000))   # sum() consumes the generator

When passing a generator directly to a function like sum(), max(), min(), or any(), you can drop the extra parentheses:

python
total = sum(n ** 2 for n in range(1000))   # one set of parens, not two

For most everyday code, list comprehensions are fine. Use generators when you are processing large datasets or streaming data where holding everything in memory would be wasteful.

JunoGenerator expressions A generator looks like a list comprehension with parentheses instead of square brackets, but it makes values one at a time instead of building the whole list up front. For a giant sequence that saves a pile of memory. The neat case: drop one straight into sum() or max() and skip building the list at all.
JunoGenerator expressions A generator expression returns a lazy iterator: it computes the next value only when asked, so nothing builds the full list in memory. Best when the result feeds straight into sum(), max(), or any(). Remember it is single-use, once consumed it is empty.
JunoGenerator expressions Lazy and flat in memory however big the input, so it is the right tool for a large or streaming source feeding sum() or any(). The catch: it is single-use, and a second loop over an exhausted generator runs zero times with no error. Need two passes? Build a list once.

zip()

zip() pairs items from two or more sequences together so you can loop through them in parallel. It stops at the shortest sequence. It is the clean way to avoid managing indexes when two lists correspond to each other.

zip() returns a lazy iterator of tuples, consuming its input iterables in step. It stops at the shortest input: longer sequences are silently truncated. For sequences that may differ in length, itertools.zip_longest() fills shorter ones with a specified value.

zip() walks its inputs in step and stops the moment the shortest one runs out. That truncation is the gotcha to design around: pair a list of 1000 records with a list of 999 and you silently lose the last record, no error, no warning. When the lengths are supposed to match, zip(seq_a, seq_b, strict=True) (Python 3.10 and up) raises if they differ instead of dropping data, and itertools.zip_longest fills the gaps when they are allowed to differ. The other trick worth knowing: zip(*rows) transposes, turning a list of rows into a list of columns, because * unpacks the outer list into separate arguments.

python
names = ["Alice", "Bob", "Carol"]
scores = [87, 74, 92]

for name, score in zip(names, scores):
    print(f"{name}: {score}")
# Alice: 87
# Bob: 74
# Carol: 92

zip() stops at the shortest sequence. If your sequences might be different lengths, use itertools.zip_longest() with a fill value.

To convert back from a zipped list of pairs into two separate lists, use zip(*pairs):

python
pairs = [("Alice", 87), ("Bob", 74), ("Carol", 92)]
names, scores = zip(*pairs)
# names = ("Alice", "Bob", "Carol")
# scores = (87, 74, 92)

*pairs unpacks the list into separate arguments, so zip(*pairs) becomes zip(("Alice", 87), ("Bob", 74), ("Carol", 92)). The * operator is covered in the Functions chapter.

zip() is also the clean way to iterate multiple sequences in parallel without managing indexes manually:

python
before = [10, 20, 30]
after = [15, 18, 35]

for b, a in zip(before, after):
    change = a - b
    print(f"{b} -> {a} ({'+' if change >= 0 else ''}{change})")
Junozip()zip() pairs up two or more sequences so you can loop them together, no index juggling. It stops at the shortest one, so mismatched lengths quietly lose the extras. And zip(*pairs) runs it in reverse, splitting a list of tuples back into separate lists.
Junozip()zip() returns a lazy iterator of tuples, stepping through its inputs in parallel. It stops at the shortest, so longer sequences get truncated without a peep. itertools.zip_longest() fills the gaps when lengths legitimately differ, and zip(*pairs) unzips.
Junozip() Lazy, parallel, and it stops at the shortest input, which silently drops data when lengths were meant to match. Pass strict=True to make a mismatch raise instead. zip(*rows) is your transpose, columns from rows in one call.

map() and filter()

map() and filter() are older functional-style tools that do what comprehensions do. You will see them in older code, so it is worth knowing what they mean. Prefer comprehensions for new code; they are more readable to most Python developers.

map(func, iterable) returns a lazy iterator that applies func to each item. filter(func, iterable) returns a lazy iterator of items for which func is truthy. Both pre-date comprehensions. Prefer comprehensions in new code; use map() when you already have a named function that does what you need.

Both return lazy iterators, not lists, so you wrap them in list() when you want the values now. They map one-to-one onto comprehensions you already know: map(f, it) is (f(x) for x in it), and filter(pred, it) is (x for x in it if pred(x)). That equivalence is the decision rule. With an inline lambda, the comprehension reads better and is the modern default. With a named function that already does the job, list(map(int, strings)) reads as "map int over strings" and is the cleaner choice, so the only real call is whether you already have a function to hand.

python
numbers = [1, 2, 3, 4, 5]

list(map(lambda x: x ** 2, numbers))         # [1, 4, 9, 16, 25]
list(filter(lambda x: x % 2 == 0, numbers))  # [2, 4]

Prefer comprehensions; they are more readable to most Python developers. Use map() when you have a named function that already exists:

python
strings = ["1", "2", "3"]
numbers = list(map(int, strings))   # [1, 2, 3] (cleaner than a comprehension here)
Junomap() and filter()map(func, iterable) runs a function over every item; filter(func, iterable) keeps only the items where the function comes back truthy. They're the older way to do what comprehensions do, so you'll meet them in other people's code. For your own, a comprehension reads clearer to most folks.
Junomap() and filter()map() transforms each item, filter() keeps the truthy ones, both lazy so wrap in list() to see results. Comprehensions are the default for new code. The one spot map() wins is a named function you already have: map(int, strings) reads cleaner than the comprehension.
Junomap() and filter()map(f, it) is (f(x) for x in it) and filter(pred, it) is (x for x in it if pred(x)), both lazy. So the choice is purely readability: with an inline lambda, the comprehension wins; with a function already named, map(int, strings) wins.

In practice

Filter a player list to passing scores, rank by score with sorted and a lambda, then print with enumerated positions:

python
players = [
    {"name": "Alice", "score": 87},
    {"name": "Bob", "score": 42},
    {"name": "Carol", "score": 96},
    {"name": "Dave", "score": 55},
]

passing = [p for p in players if p["score"] >= 60]
ranked = sorted(passing, key=lambda p: p["score"], reverse=True)
score_map = {p["name"]: p["score"] for p in ranked}

for i, (name, score) in enumerate(score_map.items(), start=1):
    print(f"{i}. {name}: {score}")

Filter a user list for active admins, build an id-to-name lookup dict, and collect sorted names in one pass each:

python
raw_users = [
    {"id": 1, "name": "Alice", "role": "admin", "active": True},
    {"id": 2, "name": "Bob", "role": "user", "active": False},
    {"id": 3, "name": "Carol", "role": "admin", "active": True},
    {"id": 4, "name": "Dave", "role": "user", "active": True},
]

active_admins = [u for u in raw_users if u["active"] and u["role"] == "admin"]
id_map = {u["id"]: u["name"] for u in raw_users}
names = sorted(u["name"] for u in raw_users if u["active"])

print(f"Active admins: {[u['name'] for u in active_admins]}")
print(f"All active: {names}")

Pair feature names with importance scores using zip, build a dict comprehension, sort with a lambda, and normalise values in a second comprehension:

python
feature_names = ["age", "income", "score", "tenure"]
importances = [0.12, 0.34, 0.28, 0.26]

feat_dict = {f: i for f, i in zip(feature_names, importances)}
top_feats = sorted(feat_dict.items(), key=lambda x: x[1], reverse=True)[:2]

print("Top 2 features:")
for name, score in top_feats:
    print(f"  {name}: {score:.2f}")

# Normalise to sum to 1.0 (values already sum to 1 here, but shown as a pattern)
total = sum(feat_dict.values())
normalised = {k: round(v / total, 4) for k, v in feat_dict.items()}
print(f"Normalised: {normalised}")

zip pairs the two lists without building intermediate tuples. The dict comprehension builds the mapping in one expression. The sort lambda avoids a named key function. The normalisation comprehension transforms values without mutating the original dict.