Ch. 9 · Python

Python Comprehensions and Readable Transformations

How list, dict and set comprehensions scope their variables, why one fails in a class body, and when a plain loop is the better choice.

~5 min readbeginnerupdated Oct 7, 2026

A comprehension is a loop written as an expression: [f(x) for x in items if keep(x)]. Used well, it says “build this collection from that one” in a single readable line. Used badly, it hides three nested loops, a side effect and a filter inside a line nobody wants to review. Interviewers ask about comprehensions for both reasons: to check you know the scoping rules, and to see whether you know when to stop using one.

What a comprehension does with its loop variable

In Python 3, the loop variable belongs to the comprehension, not to the surrounding code:

x = "outer"
squares = [x * x for x in range(4)]
print(squares, x)
python
[0, 1, 4, 9] outer
Text

x inside the brackets is a different variable from the x outside, so the outer value survives. Python 2 leaked list-comprehension variables into the enclosing scope, and you’ll still find older blog posts warning about it. In Python 3 that’s fixed for list, set and dict comprehensions and generator expressions.

Under the hood, a comprehension used to be compiled as a hidden nested function, which is how it got its own scope. Since Python 3.12 (PEP 709) list, dict and set comprehensions are inlined for speed, but the scoping behaviour was deliberately kept the same. You shouldn’t be able to tell the difference except in tracebacks and benchmarks.

The walrus exception

An assignment expression (:=) inside a comprehension binds in the enclosing scope, on purpose, so you can keep a value you computed while filtering:

orders = [{"id": 1, "total": "19.90"}, {"id": 2, "total": "n/a"}, {"id": 3, "total": "250.00"}]
big = [t for o in orders if (t := o["total"]).replace(".", "").isdigit() and float(t) > 100]
print(big, "| t leaked:", t)
python
['250.00'] | t leaked: 250.00
Text

t is visible after the comprehension and holds the last value assigned. That’s useful for “find the first match and keep it” with any(), but in a long function it’s a variable appearing out of nowhere, so use it sparingly.

The class-body trap

This one catches experienced developers. Inside a class body, a comprehension can’t see class attributes:

class Config:
    base = 10
    values = [base + i for i in range(3)]
python
NameError: name 'base' is not defined
Text

Class bodies don’t create an enclosing scope for nested functions, and the comprehension behaves like a nested function. The one exception is the first iterable, which is evaluated in the class body before the comprehension starts. So this works, if you need it:

class Config:
    base = 10
    values = [b + i for b in [base] for i in range(3)]
print(Config.values)
python
[10, 11, 12]
Text

In real code it’s cleaner to compute such values outside the class or in a classmethod. The point for an interview is being able to explain why the first one fails: it’s the class-scope rule, not a comprehension bug.

Reading nested comprehensions

Multiple for clauses run in the same order you’d write nested loops, left to right, outermost first:

matrix = [[1, 2, 3], [4, 5, 6]]
print([n for row in matrix for n in row])
print([[row[i] for row in matrix] for i in range(3)])
python
[1, 2, 3, 4, 5, 6]
[[1, 4], [2, 5], [3, 6]]
Text

The first line flattens: for row in matrix is the outer loop, for n in row the inner one. The second is a comprehension inside a comprehension, which transposes the matrix. People often reverse the clause order in the flattening case and get NameError: name 'row' is not defined, because the inner clause refers to a name the outer clause hasn’t introduced yet. A trick that helps: write the nested for loops on separate lines, then delete the colons and line breaks.

List, generator, dict or set

The bracket type decides what you get, and choosing the right one is part of writing readable code:

  • [...] builds a list in memory. Use it when you need indexing, len(), or more than one pass.
  • (...) is a generator expression. It produces items one at a time and stores none of them.
  • {k: v ...} builds a dict, {x ...} a set.

The memory difference is large on big inputs:

import sys
nums = range(1_000_000)
print(sys.getsizeof([n * 2 for n in nums]), sys.getsizeof((n * 2 for n in nums)))
print(sum(n * 2 for n in nums))
python
8448728 208
999999000000
Text

The list costs about 8 MB for its pointer array alone (the int objects themselves are extra); the generator object is 208 bytes no matter how many items it will yield. When a comprehension feeds straight into sum, max, any, "".join or a for loop, drop the square brackets and pass a generator expression. You can omit the extra parentheses when it’s the only argument, as in sum(n * 2 for n in nums). The catch is that a generator can only be consumed once (see iterator exhaustion).

A dict comprehension is the idiomatic way to index records: {u["id"]: u for u in users}. Remember that duplicate keys silently keep the last value, which can hide data problems if ids aren’t unique.

When a loop reads better

Comprehensions are for building a collection. They’re a poor fit when:

  • The point is a side effect. [print(x) for x in items] or [db.save(r) for r in rows] builds a throwaway list of None values and tells the reader you wanted a list. Write a for loop.
  • The logic needs more than one condition and a transformation that won’t fit on a line. Once you’re nesting a conditional expression inside a filter inside two for clauses, a loop with named intermediate variables is easier to review and to debug with a breakpoint.
  • You need error handling. You can’t put try/except inside a comprehension. Moving the risky part into a small helper function (parse_total(o)) keeps the comprehension readable and the error handling explicit.

A reasonable rule: if you have to read a comprehension twice to know what it produces, rewrite it.

Interview exercise: clean up a transformation

A teammate wrote this to get the email domains of active users, lower-cased and de-duplicated:

domains = list(set([u["email"].split("@")[1].lower() for u in users if u["active"] == True and "@" in u["email"]]))
python

What would you change, and why?

Answer

Several small things. Use a set comprehension directly, {... for u in users if ...}, instead of building a list and converting it, which allocates twice. Drop == True; if u["active"] is the idiom, and == True also rejects truthy values such as the string "yes" (though 1 == True passes, which makes the check inconsistent as well as noisy). Use rpartition("@")[2] rather than split("@")[1] so an address with an unusual local part doesn’t pick the wrong segment. Finally, decide whether the caller needs a list at all: if they only check membership or iterate, return the set. If order matters, return sorted(...), because converting a set to a list gives an arbitrary order that can change between runs. The result is domains = {u["email"].rpartition("@")[2].lower() for u in users if u["active"] and "@" in u["email"]}, and if that still feels long, a helper like email_domain(u) makes the comprehension read like a sentence.

Further practice

More in Python

read ✓Python · mid

Python Closures and Late Binding

Why lambdas created in a loop all return the last value, what a closure cell really stores, and the three clean ways to capture a value.

~5 min readread →
read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc