Abdul MoizSenior Full-Stack & Applied AI Engineer
← All articles

Reading Dense Python: Comprehensions, zip(), enumerate(), and the * Operator

One line of Python can stack five separate ideas on top of each other. Here is how to take that line apart from the inside out, so the shorthand stops looking like magic and starts looking like composition.

I came to Python from JavaScript and from backend work, and for a long time a specific shape of Python code would stop me cold:

python
*[
    _search_variant(index, q, embed_result)
    for index, (q, embed_result) in enumerate(zip(queries, embed_results))
]

Nothing in there is advanced. That's what made it frustrating. It is five ordinary ideas stacked into one expression:

  • a list comprehension
  • zip()
  • enumerate()
  • tuple unpacking, nested one level deep
  • the * unpacking operator

Stacked, they read as one dense symbol. Taken apart, each one is a two-line concept. This post takes them apart in the order they compose, then puts the original expression back together.


The comprehension is a loop that returns a list

Start with the loop you would write without thinking:

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

result = []
for number in numbers:
    result.append(number * 2)

A list comprehension is that exact loop, written as an expression:

python
result = [number * 2 for number in numbers]   # [2, 4, 6, 8, 10]

The shape is always the same:

python
[expression for item in iterable]

Read it as: for every item in the iterable, evaluate expression and append the result. The expression does not have to be arithmetic — it can be any call:

python
def normalize(value):
    return value.lower().strip()

names = [" ABDUL ", " MOIZ ", " PYTHON "]

normalized = [normalize(name) for name in names]   # ["abdul", "moiz", "python"]

That is the whole feature. Everything that follows is about what you put in the for clause.


Tuple unpacking: naming the parts as you iterate

When the items are tuples, you can bind their parts to names directly in the loop header:

python
users = [
    (1, "Abdul"),
    (2, "Ali"),
    (3, "Sara"),
]

for user_id, name in users:
    print(user_id, name)

On the first iteration Python is effectively doing user_id = 1 and name = "Abdul". The alternative — user[0], user[1] — works, but it costs you the names.

The same binding works inside a comprehension:

python
names = [name for user_id, name in users]   # ["Abdul", "Ali", "Sara"]

Mental model: for a, b in values means each item holds two things, and I'm naming them here instead of indexing them later.


zip() pairs things by position

Two lists where position i in one corresponds to position i in the other:

python
queries = ["red dress", "black shirt", "blue jeans"]
embed_results = ["embedding_1", "embedding_2", "embedding_3"]

zip() walks them together and yields tuples:

python
list(zip(queries, embed_results))
python
[
    ("red dress", "embedding_1"),
    ("black shirt", "embedding_2"),
    ("blue jeans", "embedding_3"),
]

Which means it composes directly with the unpacking you just saw:

python
for query, embedding in zip(queries, embed_results):
    print(query, embedding)

And directly with a comprehension:

python
def search(query, embedding):
    return f"Searching {query} using {embedding}"

results = [
    search(query, embedding)
    for query, embedding in zip(queries, embed_results)
]

Conceptually that runs search("red dress", "embedding_1"), then search("black shirt", "embedding_2"), then the third, and collects the return values.

One caveat worth knowing early: zip() stops at the shortest input. If queries has four items and embed_results has three, you silently get three pairs. On Python 3.10+, zip(a, b, strict=True) raises instead — worth using when a length mismatch means something upstream is broken.


enumerate() adds an index

Sometimes you need the position too:

python
for index, query in enumerate(queries):
    print(index, query)
text
0 red dress
1 black shirt
2 blue jeans

enumerate() conceptually turns ["red dress", ...] into [(0, "red dress"), (1, "black shirt"), ...]. The index is not a key in a dictionary — it is simply the first element of a tuple, (index, value), which is why the same for a, b in ... unpacking applies.


Combining them creates a nested tuple

Here is the step that trips people up. Start with the pairs:

python
zip(queries, embed_results)
# ("red dress", "embedding_1"), ("black shirt", "embedding_2"), ...

Now wrap that in enumerate(). enumerate() does not know or care that its items are already tuples — it just puts an index in front of each one:

python
enumerate(zip(queries, embed_results))
python
[
    (0, ("red dress", "embedding_1")),
    (1, ("black shirt", "embedding_2")),
    (2, ("blue jeans", "embedding_3")),
]

So each item is a tuple whose second element is itself a tuple. Python unpacks both levels at once if you mirror the shape with parentheses:

python
for index, (query, embedding) in enumerate(zip(queries, embed_results)):
    print(index, query, embedding)

First iteration: index = 0, query = "red dress", embedding = "embedding_1".

The parentheses around (query, embedding) are not decoration. They tell Python the second element is itself a pair, split it too. Write for index, query, embedding in ... and you get a ValueError about unpacking, because there are two elements at the top level, not three.

Put that iteration into a comprehension and you have most of the original expression:

python
[
    _search_variant(index, q, embed_result)
    for index, (q, embed_result) in enumerate(zip(queries, embed_results))
]

which is exactly:

python
results = []
for index, (q, embed_result) in enumerate(zip(queries, embed_results)):
    results.append(_search_variant(index, q, embed_result))

The * operator spreads an iterable into arguments

The last piece. In a function call, * means unpack this iterable into separate positional arguments. If you know JavaScript, it is the spread operator:

javascript
const numbers = [1, 2, 3];
console.log(...numbers);
python
numbers = [1, 2, 3]
print(*numbers)

Both pass three arguments rather than one list. So:

python
def add(a, b, c):
    return a + b + c

numbers = [10, 20, 30]

add(numbers)    # TypeError — one argument, three expected
add(*numbers)   # add(10, 20, 30)

** does the same thing for keyword arguments, from a dict:

python
def create_user(name, age):
    print(name, age)

user = {"name": "Abdul", "age": 28}

create_user(**user)   # create_user(name="Abdul", age=28)

The same symbol, pointing the other way

* is easier to remember once you see both directions. In a call, it spreads values out. In a definition, it collects them back in:

python
def add(*numbers):
    print(numbers)

add(10, 20, 30)   # (10, 20, 30)

So add(*[10, 20, 30]) spreads a list into three arguments, and def add(*numbers) gathers three arguments back into a tuple. Same symbol, opposite ends of the call.


Why this shows up with asyncio.gather()

The reason I kept meeting that expression is that asyncio.gather() takes coroutines as separate arguments:

python
await asyncio.gather(coro_1, coro_2, coro_3)

But a comprehension produces a list of coroutines, not separate arguments. Passing the list directly gathers one thing — a list — which is not awaitable. So you spread it:

python
await asyncio.gather(
    *[
        _search_variant(index, q, embed_result)
        for index, (q, embed_result) in enumerate(zip(queries, embed_results))
    ]
)

Expanded, with no shorthand at all:

python
coroutines = []
for index, (q, embed_result) in enumerate(zip(queries, embed_results)):
    coroutines.append(_search_variant(index, q, embed_result))

await asyncio.gather(*coroutines)

Worth noticing why the comprehension is safe here: calling an async def function does not run it. It builds a coroutine object and returns immediately. The comprehension is just constructing three objects; gather() is what actually runs them concurrently.


Filtering, and the three parts of a comprehension

A comprehension can also drop items:

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

even = [number for number in numbers if number % 2 == 0]   # [2, 4, 6]

The full shape has three distinct jobs, and separating them makes any comprehension readable:

python
[
    number * 2          # transformation — what goes into the new list
    for number in numbers   # iteration — where items come from
    if number > 3       # filter — which items survive
]

For [1, 2, 3, 4, 5] that gives [8, 10]: keep 4 and 5, double each. Read it as iterate, filter, transform — even though the transformation is written first.

That combination is everywhere in real code:

python
users = [
    {"name": "Abdul", "active": True},
    {"name": "Ali", "active": False},
    {"name": "Sara", "active": True},
]

active_names = [user["name"] for user in users if user["active"]]   # ["Abdul", "Sara"]

And the same syntax builds dicts and sets — only the brackets change:

python
{number: number * 2 for number in [1, 2, 3]}   # {1: 2, 2: 4, 3: 6}
{word.lower() for word in words}               # a set, deduplicated

Reading the original expression

Back to where we started:

python
*[
    _search_variant(index, q, embed_result)
    for index, (q, embed_result) in enumerate(zip(queries, embed_results))
]

Read it from the inside out, which is also the order the data flows:

  1. zip(queries, embed_results) → pairs each query with its embedding: (query, embedding)
  2. enumerate(...) → adds a position: (index, (query, embedding))
  3. for index, (q, embed_result) → unpacks both levels into three names
  4. _search_variant(index, q, embed_result) → one call per pair
  5. [ ... ] → collects the results into a list
  6. * → spreads that list into separate arguments

In English: pair every query with its corresponding embedding, number the pairs, call _search_variant() once per pair, collect the calls into a list, then spread that list into separate arguments.


The cheat sheet

python
[x * 2 for x in values]                  # transform
[normalize(x) for x in values]           # call a function
[x for x in values if x > 10]            # filter
[name for user_id, name in users]        # tuple unpacking
{x: x * 2 for x in values}               # dict comprehension

for query, embedding in zip(queries, embeddings): ...
for index, value in enumerate(values): ...
for index, (query, embedding) in enumerate(zip(queries, embeddings)): ...

func(*values)        # spread a list into positional args   (JS: func(...values))
func(**mapping)      # spread a dict into keyword args
def func(*args):     # collect positional args into a tuple

The habit that made all of this stop being hard was small: when a line has more than one idea in it, expand it into the loop it stands for, mentally or in a scratch file. Once you can write the expanded version, the compressed version stops reading as complexity. It is just composition, and every piece of it is something you already knew.


I write about the systems behind shipping AI features — RAG pipelines, evals, and the gap between demo and production — in my newsletter, AI Shipped. New issue every week.

ShareXLinkedIn
Read nextDesigning an Air-Gapped RAG Architecture for Healthcare
AM
Portfolio Assistant
Online
Hey there! 👋 I'm Abdul Moiz's portfolio assistant. Ask me about services, projects, tech stack, or anything about working together!