Quick Answer

enumerate gives you the index and the value together while looping, replacing range(len(items)) and the manual counter that usually accompanies it. zip walks several sequences in parallel, pairing their items, and stops at the shortest one unless you pass strict=True. Together they remove almost every reason to index into a list inside a loop, which makes the code shorter and removes a whole class of off-by-one errors.

enumerate: Index and Value Together

Two patterns appear constantly in code written by people coming from other languages.

# The manual counter
i = 0
for name in names:
    print(i, name)
    i += 1

# The range(len()) loop
for i in range(len(names)):
    print(i, names[i])

Both work. Both are more code than necessary, and both give you an index you must then use correctly.

for i, name in enumerate(names):
    print(i, name)

That is the whole thing. enumerate yields pairs of index and value, unpacked directly into two variables.

It takes a starting number, which is useful whenever the count is shown to a human:

for rank, student in enumerate(toppers, start=1):
    print(f"{rank}. {student}")
# 1. Riya
# 2. Amit

Without start=1 the usual workaround is writing i + 1 everywhere inside the loop, which is exactly the kind of small repeated arithmetic that eventually goes wrong.

A useful reminder: if you are not actually using the index, do not ask for it. for name in names is the cleanest loop of all, and reaching for enumerate out of habit adds noise.

zip: Walking Sequences in Parallel

When two lists correspond to each other position by position, indexing both is the clumsy way.

# Index-based
for i in range(len(names)):
    print(names[i], scores[i])

# zip
for name, score in zip(names, scores):
    print(name, score)

zip takes any number of iterables and yields tuples of their corresponding items.

for name, score, city in zip(names, scores, cities):
    print(f"{name} scored {score} in {city}")

It is also the neat way to build a dictionary from two parallel lists:

marks = dict(zip(names, scores))
# {'Riya': 91, 'Amit': 84}

And to transpose rows into columns, which looks like a trick the first time you see it:

rows = [(1, 2, 3), (4, 5, 6)]
cols = list(zip(*rows))
# [(1, 4), (2, 5), (3, 6)]

The * spreads the rows as separate arguments, so zip pairs the first element of each, then the second, and so on.

The Trap: zip Stops at the Shortest

This is the behaviour that silently loses data, and it is worth knowing before you meet it in production.

names  = ['Riya', 'Amit', 'Neha']
scores = [91, 84]                    # one missing

for name, score in zip(names, scores):
    print(name, score)
# Riya 91
# Amit 84
# Neha is simply never processed — no error, no warning

If those lists were supposed to be the same length, a record has vanished and nothing told you. That is a genuinely nasty class of bug because the code looks correct and runs cleanly.

Python 3.10 added a guard:

for name, score in zip(names, scores, strict=True):
    ...
# ValueError: zip() argument 2 is shorter than argument 1

Use strict=True whenever the lists are supposed to match. Turning a silent truncation into a loud error is almost always the right trade.

If the lengths are legitimately different and you want everything, itertools.zip_longest pads instead of truncating:

from itertools import zip_longest

for name, score in zip_longest(names, scores, fillvalue=0):
    print(name, score)
# Neha 0

Using Them Together

They compose, which covers the case where you need an index across parallel lists.

for i, (name, score) in enumerate(zip(names, scores), start=1):
    print(f"{i}. {name}: {score}")

Note the parentheses around (name, score). enumerate yields a pair of index and item, and here the item is itself a tuple, so it must be unpacked as a nested structure. Forgetting the brackets gives a confusing "not enough values to unpack" error.

Both also work inside comprehensions, which is where they are most idiomatic:

numbered = [f"{i}. {n}" for i, n in enumerate(names, 1)]
passed   = [n for n, s in zip(names, scores) if s >= 40]
totals   = [a + b for a, b in zip(list1, list2)]

One property worth knowing: both return lazy iterators, not lists. They produce values on demand, so they cost no memory for large inputs — but they can only be consumed once.

pairs = zip(names, scores)
print(list(pairs))    # [('Riya', 91), ...]
print(list(pairs))    # [] — already exhausted

If you need the result twice, materialise it once with list() and reuse that.

When Indexing Is Still Correct

These functions remove most index-based loops, not all of them. There are cases where an index is genuinely the right tool.

  • You need to compare neighbours. Looking at items[i] and items[i+1] is clear with an index — though zip(items, items[1:]) pairs each element with the next quite neatly.
  • You need to modify the list in place. for i, x in enumerate(items): items[i] = f(x) works, whereas rebinding x would not change the list.
  • You are stepping irregularly — skipping ahead, or moving backwards. A while loop with an explicit index is honest about that.
  • Two pointers. Algorithms that move indexes independently need real indexes.

The general rule stands, though: if you see range(len(...)), there is usually a better way. Removing manual index arithmetic removes off-by-one errors, and reviewers reading Python will notice the difference immediately.

One last caution — do not modify a list while iterating over it. Removing items shifts everything after the current position, so the loop skips elements. Build a new list with a comprehension or iterate over a copy instead.

Frequently Asked Questions

What does enumerate actually return? A lazy iterator producing tuples of (index, value). It does not build a list, so it uses constant memory regardless of input size, but it can only be consumed once. Wrap it in list() if you need to reuse the result.
Why does zip stop at the shortest list? Because it pairs items positionally and cannot pair what is not there. The risk is that unequal lengths silently drop data with no error. Pass strict=True on Python 3.10 or later to raise instead, or use itertools.zip_longest to pad.
How do I start enumerate from 1? Pass the start argument: enumerate(items, start=1). This is better than adding one inside the loop, especially when the number is displayed to a user, because the arithmetic appears once rather than at every use.
Can I use zip on more than two lists? Yes, it accepts any number and yields tuples of the corresponding items from each. It still stops at the shortest of them, so strict=True is worth using when they should all be the same length.
Is range(len(list)) ever correct? Occasionally — when you need to modify the list in place, compare an element to its neighbour, or step irregularly. For plain iteration where you need the index and value, enumerate is clearer and avoids index arithmetic.