The counter version and the enumerate version. The second argument is the part people miss.
items = ['a', 'b', 'c']
index = 0
for item in items:
print(index, item)
index += 1 # three separate lines, all coupled
for number, item in enumerate(items, start=1):
print(number, item) # -> 1 a / 2 b / 3 c
- enumerate yields pairs
Each step gives you the position and the item together, so the two can never drift apart. The manual version keeps them in different places and relies on you to keep them in step.
- start= saves the off-by-one
Humans count lines from 1 and Python counts from 0, so the alternative is writing index + 1 at every use — and getting it right at every use. enumerate(items, start=1) settles it once, at the top.
- The counter breaks the day somebody adds a continue
Skip an item with `continue` and the increment at the bottom never runs, so every later number is wrong. enumerate cannot develop that bug, because there is nothing for the skip to jump over.
The counter exists only to tell you where you are in the loop. Python has a way to be told that without keeping track yourself.
enumerate(lines) yields (position, line) pairs starting at 0. Its start argument moves that, which removes the +1 you would otherwise write.
A single comprehension over enumerate(lines, start=1), building the f-string from both parts of each pair. No result list to append to and no counter to maintain.
def numbered_lines(lines): return [f'{number}: {line}' for number, line in ____(lines, ____=1)]
def numbered_lines(lines):
return [f"{number}: {line}"
for number, line in enumerate(lines, start=1)]
The initialisation, the increment and the coupling between them all disappear — not because they were badly written, but because enumerate already does that job. Deleting state is usually a bigger win than shortening code, and here it happens to do both.
enumerate(lines, 1) works and reads as a mystery number. Naming it says 'this is where the counting begins' to somebody who has not memorised the signature. Keywords cost nothing at runtime and are worth using wherever the argument is a bare literal.
Nothing is wrong with the starter today. The reason to change it is what happens when a filter is added inside the loop six months from now — the increment sits after the `continue` and every subsequent number is silently off. Refactoring is often about removing the room for a bug rather than fixing one.
Every version below is correct and verified against the same cases by the build. They are here because knowing which one to reach for is the part that takes judgement, and the trade-off is stated rather than left for you to guess.
def numbered_lines(lines):
return [f"{index + 1}: {line}" for index, line in enumerate(lines)]Perfectly acceptable, and the version you will see most often. start=1 wins narrowly because the adjustment happens once at the top rather than at every use — which starts to matter the moment the number appears in two places.
If you never use the index, do not enumerate at all — `for line in lines` is the honest version, and a linter will point out an unused loop variable. If you are walking two sequences together, that is zip rather than enumerate plus a subscript. And if the numbers need aligning into a column, the f-string needs a width spec, at which point the comprehension may be worth unrolling for readability.
- Right-align the numbers so a hundred-line file lines up, and work out where the width has to come from.
- Add a start parameter so a caller can number a page of results continuing from the last one, and check what it does to the empty case.
- Filter out blank lines *and* keep the original numbering, which is exactly the situation where a manual counter goes wrong.