for i in range(len(items)) is the loop people bring from other languages. Python has two built-ins that say what you actually mean, and one flag worth knowing about.
Before
runners = ["Ada", "Linus", "Grace"] for i in range(len(runners)): print(i + 1, runners[i])
1 Ada 2 Linus 3 Grace
After
runners = ["Ada", "Linus", "Grace"] for place, name in enumerate(runners, start=1): print(place, name)
1 Ada 2 Linus 3 Grace
No + 1, no indexing, and the names say what each value is.
Two lists side by side: zip, and its trap
zip walks two lists together. By default it stops at the shorter one, without a word:
names = ["Ada", "Linus", "Grace"] times = [41.2, 43.9] for name, t in zip(names, times): print(name, t)
Ada 41.2 Linus 43.9
Grace has vanished. Since Python 3.10, strict=True turns that into an error you can't miss:
names = ["Ada", "Linus", "Grace"] times = [41.2, 43.9] try: for name, t in zip(names, times, strict=True): print(name, t) except ValueError as e: print("ValueError:", e)
Ada 41.2 Linus 43.9 ValueError: zip() argument 2 is shorter than argument 1
Why it works
enumerate wraps any iterable and hands back (position, item) pairs; zip hands back one item from each iterable at a time. Neither needs the data to support indexing, so both work on files, generators and sets, where range(len()) can't.
When not to use it
If you need to change a list in place by position (prices[i] *= 1.1), enumerate still helps, but a comprehension that builds a new list is usually clearer. And use strict=True whenever the lists are meant to match; the default truncation is only right when you really do want "as many as the shorter one has".