Unpacking in Comprehensions (PEP 798)
Unpacking in Comprehensions (PEP 798)
Thesis
PEP 448 gave us [*a, *b] and {**d1, **d2} for a fixed number of iterables. Flattening an arbitrary number still meant a double for, itertools.chain.from_iterable, or an explicit loop with extend / update. PEP 798 (final; Python 3.15) allows * / ** at the front of comprehension and generator-expression elements so the fixed-arity idea scales:
[*it for it in its] # concatenate iterables → list
{*it for it in its} # union iterables → set
{**d for d in dicts} # merge mappings → dict (later keys win)
(*it for it in its) # concatenate → generatorThis chapter is only PEP 798. Lazy imports (PEP 810) are a separate 3.15 feature and are not covered here.
Version note: semantics and examples follow PEP 798 and What’s New in Python 3.15. The authoring box runs CPython 3.13.5, which still rejects the syntax (SyntaxError: iterable unpacking cannot be used in comprehension). Listings below are therefore specified for 3.15; run them with a 3.15 interpreter (uv run --python 3.15 or a local 3.15 install). Expected outputs are taken from the PEP / What’s New examples.
Mental model
Treat a starred comprehension like the non-starred one, but swap append/add/set-item for extend/update:
out = [*expr for x in it] out = []
for x in it:
out.extend(expr)
out = {*expr for x in it} out = set(); out.update(expr) each time
out = {**expr for x in it} out = {}; out.update(expr) each time
g = (*expr for x in it) for x in it:
for item in expr: yield item
| Form | Meaning | Classic equivalent |
|---|---|---|
[*xs for xs in rows] |
Flatten to list | [x for xs in rows for x in xs] |
{*xs for xs in rows} |
Flatten to set | {x for xs in rows for x in xs} |
{**d for d in dicts} |
Merge dicts | {k: v for d in dicts for k, v in d.items()} |
(*xs for xs in rows) |
Flatten lazily | (x for xs in rows for x in xs) |
Rules that keep the grammar sane:
*/**only at the top level of the comprehension element — not inside a conditional without parentheses, not as a dict key/value side.**is for dict comprehensions only;**in a listcomp or genexp is aSyntaxError.f(*x for x in y)meansf((*x for x in y))— one generator argument — matching the usual “genexp as sole argument needs no extra parens” rule. To unpack into arguments, writef(*(*x for x in y)).- Async forms work the same way:
(*a async for a in agen())≡(x async for a in agen() for x in a). - Evaluation order and walrus scoping are unchanged;
*(y := …)still bindsyin the containing scope for genexps.
Worked examples
Case 1: Flatten a list of lists (the StackOverflow classic)
# pep798_flatten.py — run on Python 3.15+
rows = [[1, 2], [3, 4], [5]]
flat = [*row for row in rows]
assert flat == [1, 2, 3, 4, 5]
assert flat == [x for row in rows for x in row]
print(flat)$ uv run --python 3.15 python pep798_flatten.py
[1, 2, 3, 4, 5]
On 3.13 (this box), the same file fails at compile time:
$ python3 pep798_flatten.py
File "pep798_flatten.py", line 3
flat = [*row for row in rows]
^^
SyntaxError: iterable unpacking cannot be used in comprehension
Case 2: Set union and dict merge
# pep798_merge.py — run on Python 3.15+
sets = [{1, 2}, {2, 3}, {3, 4}]
print({*s for s in sets}) # {1, 2, 3, 4}
dicts = [{"a": 1}, {"b": 2}, {"a": 3}]
print({**d for d in dicts}) # {'a': 3, 'b': 2} — last write wins$ uv run --python 3.15 python pep798_merge.py
{1, 2, 3, 4}
{'a': 3, 'b': 2}
Dict merging keeps ordinary {**a, **b} semantics: later keys overwrite earlier ones. That is a feature when you layer defaults → overrides; it is a footgun when you expected a deep merge.
Case 3: Replace chain.from_iterable in streaming code
# pep798_gen.py — run on Python 3.15+
def chunks():
yield (10, 11)
yield (12,)
yield (13, 14, 15)
gen = (*chunk for chunk in chunks())
print(list(gen))$ uv run --python 3.15 python pep798_gen.py
[10, 11, 12, 13, 14, 15]
The PEP’s generator semantics use an explicit inner loop, not yield from delegation. Advanced generator protocol methods (.send / .throw / .close) therefore do not forward into subgenerators the way a hand-written yield from would. For ordinary iteration this never matters.
Case 4: Stdlib-shaped refactors (from the PEP)
Patterns the PEP illustrates from the standard library:
# Before: explicit loop
comments = []
for token in self:
comments.extend(token.comments)
return comments
# After (3.15+):
return [*token.comments for token in self]# Before:
exceptions = [exc for sub in exceptions for exc in sub]
# After:
exceptions = [*sub for sub in exceptions]# Before:
inherited_slots = set(
itertools.chain.from_iterable(map(_get_slots, cls.__mro__[1:-1]))
)
# After:
inherited_slots = {*_get_slots(c) for c in cls.__mro__[1:-1]}Prefer the new form when it removes a throwaway loop variable and keeps the operation “concatenate these iterables.” Keep chain.from_iterable when you are already in an itertools pipeline or need to share one lazy chain across list/set/dict constructors.
Case 5: Function-call parentheses (read carefully)
# pep798_call.py — conceptual; run on 3.15+
def f(*args):
return args
rows = [[1, 2], [3]]
# ONE argument: a generator that yields 1, 2, 3
assert len(f(*x for x in rows)) == 1
# MANY arguments: unpack the flattened generator
assert f(*(*x for x in rows)) == (1, 2, 3)If that first form surprises you, always add the inner parentheses for humans: f((*x for x in rows)).
The trap
- Reversing the old double-
for. People write[x for x in xs for xs in rows]by impulse. Starred form removes that footgun — but only if you actually use it. - Assuming deep merge.
{**d for d in dicts}is shallow overwrite, same as successiveupdates. - Starred genexp as
*argswithout a second star.f(*x for x in rows)passes one generator, not many integers. - Portability. The syntax is a
SyntaxErroron ≤3.14. Ship a 3.15 classifier /sys.version_infogate, or keep the double-foron libraries that still support 3.13/3.14. - Overpacking.
f(*(*x for *x, _ in list_of_lists))is legal enough to hurt reviewers. Prefer a named helper once nesting obscures the data flow.
The boring rule
On Python 3.15+, use [*xs for xs in rows] (and set/dict/genexp cousins) when you mean “concatenate / union / merge these iterables.” Keep the double-for or chain.from_iterable when you target older interpreters or already sit in an itertools pipeline. Never confuse f(*x for x in it) with argument unpacking.
Try this
- On 3.15, rewrite three double-
forcomprehensions in a personal script to starred form; confirm identical results. - On 3.13/3.14, confirm each rewrite is a
SyntaxErrorso your support matrix stays honest. - Build
{**d for d in dicts}where two dicts share a key; predict the winner before you print. - Pass
(*x for x in rows)to a function with and without an extra leading*; explain thelenofargs.
Sources
- PEP 798 – Unpacking in Comprehensions (Final; Python 3.15)
- What’s New In Python 3.15 — PEP 798
- Language reference: displays for lists, sets, and dictionaries (canonical post-PEP docs linked from the PEP)