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:
*[
_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:
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:
result = [number * 2 for number in numbers] # [2, 4, 6, 8, 10]The shape is always the same:
[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:
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:
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:
names = [name for user_id, name in users] # ["Abdul", "Ali", "Sara"]Mental model:
for a, b in valuesmeans 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:
queries = ["red dress", "black shirt", "blue jeans"]
embed_results = ["embedding_1", "embedding_2", "embedding_3"]zip() walks them together and yields tuples:
list(zip(queries, embed_results))[
("red dress", "embedding_1"),
("black shirt", "embedding_2"),
("blue jeans", "embedding_3"),
]Which means it composes directly with the unpacking you just saw:
for query, embedding in zip(queries, embed_results):
print(query, embedding)And directly with a comprehension:
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:
for index, query in enumerate(queries):
print(index, query)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:
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:
enumerate(zip(queries, embed_results))[
(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:
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:
[
_search_variant(index, q, embed_result)
for index, (q, embed_result) in enumerate(zip(queries, embed_results))
]which is exactly:
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:
const numbers = [1, 2, 3];
console.log(...numbers);numbers = [1, 2, 3]
print(*numbers)Both pass three arguments rather than one list. So:
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:
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:
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:
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:
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:
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:
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:
[
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:
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:
{number: number * 2 for number in [1, 2, 3]} # {1: 2, 2: 4, 3: 6}
{word.lower() for word in words} # a set, deduplicatedReading the original expression
Back to where we started:
*[
_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:
zip(queries, embed_results)→ pairs each query with its embedding:(query, embedding)enumerate(...)→ adds a position:(index, (query, embedding))for index, (q, embed_result)→ unpacks both levels into three names_search_variant(index, q, embed_result)→ one call per pair[ ... ]→ collects the results into a list*→ 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
[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 tupleThe 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.