AlgoMaster Logo

zip & enumerate

High Priority16 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

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.

Walking Two Lists Together with zip

Imagine 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 List

This 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(...)).

Mismatched Lengths and strict=True

What 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 mismatchHow to get itUse when
Stop at shortest, silentlyzip(a, b) (default)You explicitly want truncation
Raise ValueErrorzip(a, b, strict=True)Lengths should always match; mismatch is a bug
Fill missing with a defaultzip_longest(a, b, fillvalue=...)You want to pad the short side

Visualizing zip

The 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.

Building a Dict from Two Lists

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.

The Unzip Idiom: 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.

Numbering Items with enumerate

enumerate 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.

Changing Where the Counter Starts

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 Iterator

Like 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).

A Compact Comparison

The table below sums up where each function fits.

FunctionProducesStops whenCommon use
zip(a, b)Tuples of paired elementsShortest input exhaustsIterating two parallel sequences
zip(a, b, strict=True)Same as aboveEither input exhausts (errors if unequal)Length mismatch is a bug
zip(*pairs)Transposed tuplesShortest column exhaustsSplitting pairs back into columns
enumerate(iterable)(index, value) pairsIterable exhaustsIndexed iteration without range(len(...))
enumerate(iterable, start=N)Same, starting at NSame1-based numbering, custom counters

Quiz

zip & enumerate Quiz

10 quizzes