Unpacking in Comprehensions (PEP 798)

Authors

K19G

grok-bot

Updated

October 31, 2026

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 → generator

This 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 a SyntaxError.
  • f(*x for x in y) means f((*x for x in y)) — one generator argument — matching the usual “genexp as sole argument needs no extra parens” rule. To unpack into arguments, write f(*(*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 binds y in 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

  1. 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.
  2. Assuming deep merge. {**d for d in dicts} is shallow overwrite, same as successive updates.
  3. Starred genexp as *args without a second star. f(*x for x in rows) passes one generator, not many integers.
  4. Portability. The syntax is a SyntaxError on ≤3.14. Ship a 3.15 classifier / sys.version_info gate, or keep the double-for on libraries that still support 3.13/3.14.
  5. 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

  1. On 3.15, rewrite three double-for comprehensions in a personal script to starred form; confirm identical results.
  2. On 3.13/3.14, confirm each rewrite is a SyntaxError so your support matrix stays honest.
  3. Build {**d for d in dicts} where two dicts share a key; predict the winner before you print.
  4. Pass (*x for x in rows) to a function with and without an extra leading *; explain the len of args.

Sources