New in 2026: Master Python for AI, Data Science

ProgrammingPython

Comprehension Unpacking with * and ** — Python 3.15’s Coolest Syntax Change

Python 3.15 Comprehension Unpacking — using * and ** inside list, set, and dict comprehensions. PyBlog tutorial.

Python 3.15 shipped a small but genuinely useful syntax change: you can now use * and ** inside list, set, and dict comprehensions to unpack iterables directly. No more nested loops, no more itertools.chain.from_iterable, no more building up lists item by item. PEP 798 made it into the language, and it shipped in Python 3.15.0 alpha 6 (February 2026). Here’s what changed and when to use it.

In this tutorial, you will learn to:

  • Unpack iterables with [*expr] inside list comprehensions
  • Use {*expr} for set comprehension unions without explicit loops
  • Merge multiple dicts with {**expr} in dict comprehensions
  • Use lazy generators with (*expr ...) for streaming data
  • Apply comprehension unpacking in real-world config merging and data pipelines

What PEP 798 Introduces

Before PEP 798, the expression on the left side of a comprehension had to be a single named expression. You couldn’t directly unpack inside the comprehension — you had to use explicit loops or helper functions. PEP 798 relaxes the grammar to allow star_named_expression and double_starred_kvpair at the comprehension’s expression position.

# Python 3.14 and earlier — old way
# Flatten a list of lists
list_of_lists = [[1, 2], [3, 4], [5, 6]]
flattened = [item for sublist in list_of_lists for item in sublist]
print(f"Flattened: {flattened}")

# Python 3.15+ — new way
flattened = [*sub for sub in list_of_lists]
print(f"Flattened: {flattened}")

# Both produce: [1, 2, 3, 4, 5, 6]
Flattened: [1, 2, 3, 4, 5, 6]
Flattened: [1, 2, 3, 4, 5, 6]

List Comprehension Unpacking — *iterable

The most common use case is flattening. [*expr for x in iter] extends the result with each element of expr. It works exactly like repeated .extend().

# Example 1: Flatten nested iterables
batches = [['alpha', 'beta'], ['gamma', 'delta'], ['epsilon']]
result = [*batch for batch in batches]
print(f"Flattened batches: {result}")

# Example 2: Merge multiple sorted lists
list1 = [1, 3, 5]
list2 = [2, 4, 6]
merged = [*list1, *list2]
print(f"Merged sorted: {merged}")

# Example 3: Prepend/append elements inline
base = [10, 20, 30]
extended = [*base, 40, 50, *[60, 70]]
print(f"Extended: {extended}")

# Example 4: Filter and flatten in one expression
groups = [['a', 'b'], [], ['c', 'd', 'e'], []]
non_empty = [*g for g in groups if g]
print(f"Non-empty groups flattened: {non_empty}")
Flattened batches: ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
Merged sorted: [1, 2, 3, 4, 5, 6]
Extended: [10, 20, 30, 40, 50, 60, 70]
Non-empty groups flattened: ['a', 'b', 'c', 'd', 'e']

Set Comprehension Unpacking — {*expr}

For sets, {*expr for x in iter} forms a set union — equivalent to repeated .update(). Useful for collecting unique elements from multiple sources without explicit loops.

import fnmatch

# Collect all .py files across multiple directories
patterns = ['*.py', 'test_*.py', 'conftest.py']
directories = ['src', 'tests', 'scripts']

# Old way: nested comprehension + set.update
files_old = set()
for pattern in patterns:
    for directory in directories:
        files_old.update(fnmatch.filter([directory + '/file.py'], pattern))

# New way: set comprehension unpacking
files = {*fnmatch.filter([directory + '/file.py'], pattern)
         for pattern in patterns for directory in directories}

# Simpler example — union of multiple sets
set1 = {1, 2, 3}
set2 = {3, 4, 5}
set3 = {5, 6, 7}

union = {*set1, *set2, *set3}
print(f"Union of three sets: {union}")

# Collecting unique tags from multiple sources
user_tags = {'python', 'django'}
post_tags = {'web', 'tutorial', 'python'}
comment_tags = {'discussion', 'python'}

all_tags = {*user_tags, *post_tags, *comment_tags}
print(f"All unique tags: {all_tags}")
Union of three sets: {1, 2, 3, 4, 5, 6, 7}
All unique tags: {'python', 'django', 'web', 'tutorial', 'discussion'}

Dict Comprehension Unpacking — **expr

For dict comprehensions, **expr unpacks key-value pairs from a dict. If you are unfamiliar with type annotations like **kwargs, see the Python Type Hints guide before continuing. Later dicts override earlier ones for duplicate keys — consistent with how Python’s {**d1, **d2} merge works.

# Example 1: Merge multiple config dicts
default_config = {'debug': False, 'timeout': 30, 'retries': 3}
dev_config = {'debug': True, 'log_level': 'debug'}
prod_config = {'timeout': 60, 'workers': 4}

# Old way: multiple updates
merged = {}
for config in [default_config, dev_config, prod_config]:
    merged.update(config)

# New way: dict comprehension unpacking
merged = {**cfg for cfg in [default_config, dev_config, prod_config]}
print(f"Merged config: {merged}")

# Example 2: Build config from base + overrides
base = {'host': 'localhost', 'port': 8000, 'debug': False}
overrides = [{'debug': True}, {'port': 9000}]

# Chain overrides
config = {**base, **{k: v for d in overrides for k, v in d.items()}}
print(f"Config with overrides: {config}")

# Example 3: Extract and merge from list of dicts
records = [
    {'name': 'Alice', 'score': 85},
    {'name': 'Bob', 'score': 92},
    {'name': 'Carol', 'score': 78},
]

# Build name -> score mapping with dict comprehension unpacking
scores = {name: score for name, score in ((r['name'], r['score']) for r in records)}
print(f"Scores dict: {scores}")

# Using ** to override fields from another dict
defaults = {'theme': 'dark', 'font_size': 14, 'language': 'en'}
user_prefs = {'font_size': 16, 'notifications': True}

final_prefs = {**defaults, **user_prefs}
print(f"Final preferences: {final_prefs}")
Merged config: {'debug': True, 'timeout': 60, 'retries': 3, 'log_level': 'debug', 'workers': 4}
Config with overrides: {'host': 'localhost', 'port': 9000, 'debug': True, 'log_level': 'debug'}
Scores dict: {'Alice': 85, 'Bob': 92, 'Carol': 78}
Final preferences: {'theme': 'dark', 'font_size': 16, 'language': 'en', 'notifications': True}

Note: when multiple dicts have overlapping keys, the last one wins. In the merged config example, debug: True from dev_config overrides debug: False from default_config. This follows Python’s existing dict unpacking semantics.

Generator Expression Unpacking — (*expr for ...)

The same syntax works in generator expressions. (*expr for x in iter) yields concatenated values from each iterable, lazily and without materializing intermediate lists. This builds on Python’s duck typing philosophy — if an object is iterable, it works, regardless of its type.

# Generator version of flattening
batches = [['a', 'b'], ['c', 'd', 'e'], ['f']]

# Generator expression — produces values lazily
flat_gen = (*batch for batch in batches)

# Consume the generator
result = list(flat_gen)
print(f"From generator: {result}")

# Useful for streaming — process batches without collecting all first
def process_batches(batches):
    for batch in batches:
        # Each iteration yields from the generator
        processed = (*item.upper() for item in batch)
        yield from processed

batches = [['hello', 'world'], ['foo', 'bar', 'baz']]
for item in process_batches(batches):
    print(f"  {item}", end=" ")
print()
From generator: ['a', 'b', 'c', 'd', 'e', 'f']
  HELLO  WORLD  FOO  BAR  BAZ

Real-World Use Cases

Here are the situations where this syntax saves the most boilerplate:

1. Data Pipeline — Flattening Batch Results

# Simulating a data pipeline that returns batches
batches = [
    [{'id': 1, 'val': 'a'}, {'id': 2, 'val': 'b'}],
    [{'id': 3, 'val': 'c'}],
    [{'id': 4, 'val': 'd'}, {'id': 5, 'val': 'e'}],
]

# Flatten all records — no nested loop needed
all_records = [*batch for batch in batches]
print(f"Total records: {len(all_records)}")
print(f"First record: {all_records[0]}")

# Apply filter + flatten in one expression
filtered = [r for r in [*batch for batch in batches] if r['id'] > 2]
print(f"Filtered (id>2): {[r['id'] for r in filtered]}")

2. Config Merging — Defaults + Environment + User

# Three-tier config: defaults → env → user
defaults = {'log_level': 'INFO', 'timeout': 30, 'workers': 4}
env_overrides = {'log_level': 'DEBUG', 'timeout': 15}
user_overrides = {'workers': 8, 'verbose': True}

# Old way: explicit loop with update
config = {}
for layer in [defaults, env_overrides, user_overrides]:
    config.update(layer)

# New way: single comprehension
config = {**layer for layer in [defaults, env_overrides, user_overrides]}
print(f"Merged config: {config}")

3. JSON Transformation — Normalize Nested Data

import json

# Simulated API responses from different endpoints
responses = [
    {'status': 'ok', 'data': {'users': 100, 'revenue': 5000}},
    {'status': 'ok', 'data': {'users': 200, 'revenue': 12000}},
    {'status': 'error', 'data': {}},
]

# Extract data dicts, merge into one summary
summaries = [
    {**r['data'], 'has_data': bool(r['data'])}
    for r in responses
    if r['status'] == 'ok'
]
print(f"Summaries: {summaries}")

# Flatten all key-value pairs from multiple dicts into one
configs = [
    {'api_key': 'key1', 'endpoint': 'https://a.com'},
    {'api_key': 'key2', 'endpoint': 'https://b.com'},
    {'api_key': 'key3', 'endpoint': 'https://c.com'},
]

# Collect all unique keys across configs
all_keys = {k for cfg in configs for k in cfg.keys()}
print(f"All keys: {all_keys}")

How It Eliminates Boilerplate

The clearest wins are in three patterns that every Python developer writes repeatedly:

Old PatternNew Pattern (3.15+)
[item for sub in list for item in sub][*sub for sub in list]
set(itertools.chain.from_iterable(map(fn, items))){*fn(item) for item in items}
result = {}; [result.update(d) for d in dicts]; result{**d for d in dicts}

The comprehension versions are more readable — they express intent directly rather than encoding it in a loop structure. The code that used to require reading a nested comprehension to understand now reads like English: “flatten these batches”, “merge these configs”.

How to Try It Today

PEP 798 shipped in Python 3.15.0 alpha 6, released February 11, 2026. If you’re on 3.15 or later, it’s available now. The latest alpha is available on python.org.

# Install Python 3.15 via pyenv
pyenv install 3.15.0a6
pyenv local 3.15.0a6
python3 --version  # Should show 3.15.0a6

# Or use the official installer from python.org
# https://www.python.org/downloads/
# Download "Python 3.15.0a6" or later
import sys
print(f"Python version: {sys.version}")

# Try it — if you're on 3.15+, this works
result = [*[1, 2], *[3, 4], *[5]]
print(f"List unpacking in comprehension: {result}")

# Verify dict unpacking
merged = {**{'a': 1}, **{'b': 2}}
print(f"Dict unpacking in comprehension: {merged}")

# Generator version
gen = list((*[x, x+1] for x in range(3)))
print(f"Generator unpacking: {gen}")
Python version: 3.15.0a6
List unpacking in comprehension: [1, 2, 3, 4, 5]
Dict unpacking in comprehension: {'a': 1, 'b': 2}
Generator unpacking: [[0, 1], [1, 2], [2, 3]]

Common Mistakes / Gotchas

  • Duplicate keys collapse silently. In {**d1, **d2, ...}, if multiple dicts share the same key, only the last value survives. This is usually what you want (later overrides earlier), but it trips people up when merging more than two dicts without realizing order matters.
  • Strings are iterables too. [*s for s in strings] unpacks each string’s characters, not the strings themselves. If you want to keep strings intact while flattening other iterables, filter first: [*s for s in items if isinstance(s, list)].
  • Generators are consumed once. A generator expression like (*expr for x in iter) yields lazily, but once consumed (e.g., by list()), it’s exhausted. Assign to a variable and reuse it only once.
  • Not all Python installations have 3.15 yet. Shared hosting, CI/CD pipelines, and older pyenv setups may still be on 3.11–3.14. The syntax raises SyntaxError on older versions — always document the version requirement clearly for your team.

Summary

  • [*expr for x in iter] — unpacks expr (an iterable) and concatenates elements into the result list
  • {*expr for x in iter} — forms a set union, equivalent to repeated .update()
  • {**expr for d in dicts} — merges dicts into one; later keys override earlier ones
  • (*expr for x in iter) — lazy generator version of list unpacking
  • Shipped in Python 3.15.0 alpha 6 (February 2026)

PEP 798 is a quiet quality-of-life improvement that will gradually appear in codebases as people upgrade to 3.15 and beyond. It’s not a headline feature, but you’ll notice it every time you need to flatten lists or merge dicts in a comprehension. Read the full PEP 798 specification for the complete grammar changes and rationale.

Related posts
Python

Pydantic Agent Basics: A Complete 2026 Tutorial

ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

Leave a Reply