zip pairs up items from two or more sequences so you can walk them together, and enumerate glues an index counter onto any iterable so you don't have to keep one yourself. They show up constantly in real Python code because so much of programming is "process these two lists side by side" or "number these items as I go". This lesson covers how both work, their lazy iterator nature, the edge cases that bite people (mismatched lengths, the strict=True flag added in 3.10), and a few patterns like transposing and dict-building.
zipImagine you have one list of product names and another list of their prices. You want to print each name next to its price. A common first instinct is to loop with an index, but Python has a cleaner option.
zip takes two or more iterables and yields tuples that pair up their elements: first with first, second with second, and so on. The for-loop unpacks each tuple directly into product, price, so the loop body reads like English.
You're not limited to two iterables. Pass three, four, however many you need:
Each tuple zip produces has one element per iterable, in the order you passed them.
zip Returns an Iterator, Not a ListThis trips people up. In Python 3, zip doesn't build a list of tuples up front. It returns a lazy iterator that produces tuples one at a time as you ask for them.
You see a zip object, not a list. To actually look at the contents, you either iterate over it (with a for-loop, a comprehension, etc.) or convert it with list():
The lazy behavior matters in two ways. First, zip uses constant memory regardless of input size, because it only holds one tuple at a time. That's a real win when you're zipping huge files or generators.
Second, a zip object is single-pass. Once you've iterated through it, it's exhausted. A second loop sees nothing.
If you need to walk the pairs more than once, store them: pairs = list(zip(...)).
Cost: list(zip(a, b)) allocates a new list of tuples, so memory scales with input length. For one-shot loops, skip the list() call and iterate the zip object directly to keep memory flat.
strict=TrueWhat happens if the inputs aren't the same length? By default, zip stops as soon as the shortest one runs out:
"HDMI Cable" is silently dropped. No warning, no error. That's convenient when you know the lengths match, but dangerous when you don't, because a bug in your data (a missing price, an extra product) goes unnoticed.
Python 3.10 added a strict=True keyword argument that raises ValueError if the iterables don't all have the same length:
The error surfaces the moment zip discovers the mismatch, which is partway through the loop in this example. Most production code that uses zip should pass strict=True whenever you expect the inputs to be the same length. It turns a silent data bug into a loud one.
If you actually want to fill in missing values instead of stopping or erroring, that's what itertools.zip_longest is for. Here's a one-line preview:
The three modes give you a clean menu of choices: truncate (default), fill (zip_longest), or raise (strict=True).
| Behavior on length mismatch | How to get it | Use when |
|---|---|---|
| Stop at shortest, silently | zip(a, b) (default) | You explicitly want truncation |
Raise ValueError | zip(a, b, strict=True) | Lengths should always match; mismatch is a bug |
| Fill missing with a default | zip_longest(a, b, fillvalue=...) | You want to pad the short side |
zipThe diagram below shows what zip(products, prices) produces. Each step pulls one element from each input and packages them into a tuple.
The zip iterator (in the middle) pulls one item from each input on demand, packages them as a tuple, hands it out, and then waits to be asked for the next one. Nothing is precomputed.
A common use of zip is turning two parallel lists into a dictionary. Pass the zip object to dict():
dict() accepts any iterable of two-element pairs, and zip produces exactly that shape. The first list becomes keys, the second becomes values. If keys are duplicated, the later value wins (same rule as any dict construction). For longer pipelines you'd reach for a dict comprehension, but dict(zip(...)) is hard to beat for readability when you have two clean parallel lists.
zip(*pairs)zip can also undo itself. If you have a list of pairs and you want to split them back into separate lists, the idiom is zip(*pairs):
The * unpacks the list into separate arguments. So zip(*orders) is the same as zip(("Alice", 3), ("Bob", 1), ("Carol", 5)). Now zip is pairing up the first elements (the names) and the second elements (the counts), giving you two tuples.
This trick works for any rectangular data. Here's how to transpose a table of order totals where rows are customers and columns are months:
Each tuple is now one month's totals across all three customers, exactly what you'd want to plot a "sales per month" chart. The same expression also works on tuples of tuples, generator output, or anything else iterable.
Cost: zip(*matrix) materializes one row at a time but has to consume the outer iterable up front (because * needs to unpack it). For very wide tables (millions of columns), each yielded tuple is still O(rows) in size. Memory scales with the number of rows, not columns.
enumerateenumerate solves a different but related problem: you want to loop through a sequence and also know the position of each item. The naive approach is to maintain your own counter:
That works, but the counter is bookkeeping noise. enumerate does the same thing without the manual counter:
enumerate wraps any iterable and yields (index, value) pairs. The for-loop unpacks each pair into two variables. Cleaner, less error-prone, and idiomatic.
Why is this better than range(len(products))? Two reasons. First, enumerate works on any iterable, including generators and files, where len() isn't even defined. Second, you get the value directly instead of indexing back in:
When reviewers see range(len(...)) in Python code, they reach for enumerate reflexively. It's that common.
enumerate takes an optional second argument, start, that sets the initial value of the counter. The default is 0, but you can use whatever makes sense for the context:
Setting start=1 is great for anything customer-facing: order positions, leaderboard rankings, line numbers. The default start=0 is right when you're using the index for actual list lookups, where Python is 0-indexed.
The start value doesn't have to be small or even positive. enumerate(items, start=100) numbers from 100 upward.
The counter is just an integer that gets incremented by 1 each step, starting from start.
enumerate Is Also a Lazy IteratorLike zip, enumerate returns an iterator, not a list. It only produces one (index, value) pair at a time and is exhausted after a single pass.
That means enumerate is safe to use on very large or infinite sequences without preloading them into memory. It also means you should convert with list() if you need to look at the contents twice.
You can also combine enumerate with zip when you need both an index and parallel data:
The inner zip pairs each product with its price. The outer enumerate then numbers those pairs. The parentheses around (product, price) in the for-loop target are required because Python is unpacking a nested tuple: the outer pair (index, inner_tuple) and then the inner tuple (product, price).
Cost: Nesting enumerate(zip(...)) adds one extra iterator wrapper but no real overhead. Each next() call walks both iterators once. There's no list construction unless you explicitly call list().
The table below sums up where each function fits.
| Function | Produces | Stops when | Common use |
|---|---|---|---|
zip(a, b) | Tuples of paired elements | Shortest input exhausts | Iterating two parallel sequences |
zip(a, b, strict=True) | Same as above | Either input exhausts (errors if unequal) | Length mismatch is a bug |
zip(*pairs) | Transposed tuples | Shortest column exhausts | Splitting pairs back into columns |
enumerate(iterable) | (index, value) pairs | Iterable exhausts | Indexed iteration without range(len(...)) |
enumerate(iterable, start=N) | Same, starting at N | Same | 1-based numbering, custom counters |
10 quizzes