Python For Loop with Index: enumerate(), start and Pitfalls

To get the index in a Python for loop, wrap whatever you are looping over in enumerate():

for index, fruit in enumerate(fruits):
    print(index, fruit)        # 0 apple, 1 pear, 2 plum

It hands back the position and the item on every pass, so there is no counter to maintain and no square brackets to get wrong.

Below: the start argument, what happens with dictionaries, and the bug that appears when you remove items while looping. Everything ran on Python 3.12.5.

Diagram comparing a plain Python for loop with an enumerate loop that returns both the index and the item
enumerate() adds the position without changing anything else.

Using enumerate to get the index

The loop unpacks two values instead of one:

fruits = ["apple", "pear", "plum"]

for index, fruit in enumerate(fruits):
    print(index, fruit)

print()
print("enumerate hands back a position and an item on every pass,")
print("so there is no indexing and nothing to count yourself.")

Output:

0 apple
1 pear
2 plum

enumerate hands back a position and an item on every pass,
so there is no indexing and nothing to count yourself.
Command Prompt showing a Python for loop using enumerate to print the index and the item
enumerate() returns a position and an item on each pass.

The names are yours to pick. for i, fruit in ... is the common short form, and position or row_number is clearer in longer code.

Python loop index: enumerate vs a counter vs range(len())

Three ways to reach the same output:

fruits = ["apple", "pear", "plum"]

print("counting manually:")
index = 0
for fruit in fruits:
    print("  ", index, fruit)
    index += 1

print()
print("looping over positions and looking each item up:")
for i in range(len(fruits)):
    print("  ", i, fruits[i])

print()
print("with enumerate:")
for i, fruit in enumerate(fruits):
    print("  ", i, fruit)

print()
print("all three print the same thing. The third has no counter to")
print("forget to increment and no index to get wrong.")

Output:

counting manually:
   0 apple
   1 pear
   2 plum

looping over positions and looking each item up:
   0 apple
   1 pear
   2 plum

with enumerate:
   0 apple
   1 pear
   2 plum

all three print the same thing. The third has no counter to
forget to increment and no index to get wrong.
Command Prompt comparing a manual counter, range(len()) and enumerate for looping with an index in Python
The same result three ways; only the last one has nothing to get wrong.

The manual counter works until someone adds a continue above the index += 1 line, at which point the numbering quietly breaks.

range(len(fruits)) is safe but noisy, and every item access goes through an index. What for i in range actually means covers that construct on its own.

Start the loop index at 1

enumerate() takes a second argument:

tasks = ["write", "review", "publish"]

print("default, counting from 0:")
for i, task in enumerate(tasks):
    print("  ", i, task)

print()
print("start=1, for anything a person will read:")
for number, task in enumerate(tasks, start=1):
    print("  ", number, task)

print()
print("start can be any integer:")
print(" ", list(enumerate(tasks, 100)))
print()
print("start only changes the COUNTER. It does not skip items.")

Output:

default, counting from 0:
   0 write
   1 review
   2 publish

start=1, for anything a person will read:
   1 write
   2 review
   3 publish

start can be any integer:
  [(100, 'write'), (101, 'review'), (102, 'publish')]

start only changes the COUNTER. It does not skip items.

Use start=1 whenever the number is shown to a person, such as step numbers or line numbers. Leave it at zero when the number is used to index back into the list.

It only shifts the counter. The items are untouched, so nothing is skipped.

enumerate returns an iterator, not a list of indexes

This catches people who try to reuse it:

fruits = ["apple", "pear", "plum"]

pairs = enumerate(fruits)
print("enumerate(fruits) ->", pairs)
print("type              ->", type(pairs))
print()
print("it produces values on demand, and only once:")
print("  first list(pairs)  ->", list(pairs))
print("  second list(pairs) ->", list(pairs), " <- already used up")
print()
print("call enumerate() again, or keep the list, if you need two passes:")
kept = list(enumerate(fruits))
print("  kept ->", kept)
print("  kept again ->", kept)

Output:

enumerate(fruits) -> <enumerate object at 0x0000017BC0A05B70>
type              -> <class 'enumerate'>

it produces values on demand, and only once:
  first list(pairs)  -> [(0, 'apple'), (1, 'pear'), (2, 'plum')]
  second list(pairs) -> []  <- already used up

call enumerate() again, or keep the list, if you need two passes:
  kept -> [(0, 'apple'), (1, 'pear'), (2, 'plum')]
  kept again -> [(0, 'apple'), (1, 'pear'), (2, 'plum')]

It produces pairs on demand, which is why looping over a huge list costs no extra memory. The cost is that a second pass finds nothing left.

Call enumerate() again for another pass, or store list(enumerate(items)) if you genuinely need it twice.

Loop with index over strings, tuples and dictionaries

Anything iterable works, but dictionaries behave in a way worth knowing:

print("a string gives characters:")
print(" ", list(enumerate("abc")))
print()
print("a tuple works the same:")
print(" ", list(enumerate(("x", "y"))))
print()

scores = {"ann": 90, "bob": 85}
print("a dictionary gives KEYS only, which catches people out:")
print(" ", list(enumerate(scores)))
print()
print("use .items() when you want the values too:")
print(" ", list(enumerate(scores.items())))
print()
print("and unpack the pair with its own brackets:")
for i, (name, score) in enumerate(scores.items()):
    print(f"   {i}  {name} scored {score}")

Output:

a string gives characters:
  [(0, 'a'), (1, 'b'), (2, 'c')]

a tuple works the same:
  [(0, 'x'), (1, 'y')]

a dictionary gives KEYS only, which catches people out:
  [(0, 'ann'), (1, 'bob')]

use .items() when you want the values too:
  [(0, ('ann', 90)), (1, ('bob', 85))]

and unpack the pair with its own brackets:
   0  ann scored 90
   1  bob scored 85
Command Prompt showing that enumerate on a Python dictionary yields only the keys unless items is used
Looping a dictionary yields keys, so enumerate(d) numbers the keys.

enumerate(scores) numbers the keys because that is what looping a dictionary gives you. Add .items() to get the values as well.

The pair then needs its own brackets: for i, (name, score) in .... Without them Python cannot tell how many values to unpack. There is more in iterating through a dictionary.

Loop with index over two lists using zip

Numbering two parallel lists at once:

names = ["ann", "bob"]
ages = [30, 40]

print("zip pairs two lists; enumerate numbers the pairs:")
for i, (name, age) in enumerate(zip(names, ages)):
    print(f"  {i}  {name} is {age}")

print()
print("the inner brackets matter. Without them Python cannot tell")
print("how many values it is unpacking:")
print(" ", list(enumerate(zip(names, ages))))

Output:

zip pairs two lists; enumerate numbers the pairs:
  0  ann is 30
  1  bob is 40

the inner brackets matter. Without them Python cannot tell
how many values it is unpacking:
  [(0, ('ann', 30)), (1, ('bob', 40))]

zip makes the pairs and enumerate numbers them, so the loop variable is i plus a tuple. The inner brackets are what split that tuple back out.

Why removing items inside the loop skips things

This is the bug the index makes possible:

numbers = [1, 2, 4, 5]

for i, value in enumerate(numbers):
    if value % 2 == 0:
        numbers.remove(value)

print("removing even numbers while looping over the list:")
print("  result   ->", numbers)
print("  expected ->", [n for n in [1, 2, 4, 5] if n % 2])
print("  the 4 was skipped entirely")
print()
print("removing an item shifts everything left, but the counter still")
print("moves right, so one item gets stepped over each time.")
print()

print("safe: build a new list instead")
print("  ", [n for n in [1, 2, 4, 5] if n % 2])
print()
print("safe: loop over a copy if you must remove in place")
numbers = [1, 2, 4, 5]
for value in numbers[:]:
    if value % 2 == 0:
        numbers.remove(value)
print("  ", numbers)

Output:

removing even numbers while looping over the list:
  result   -> [1, 4, 5]
  expected -> [1, 5]
  the 4 was skipped entirely

removing an item shifts everything left, but the counter still
moves right, so one item gets stepped over each time.

safe: build a new list instead
   [1, 5]

safe: loop over a copy if you must remove in place
   [1, 5]
Command Prompt showing that removing items from a Python list while looping over it causes items to be skipped
Removing while looping shifts the list left, so the 4 is never examined.

Removing an item moves everything after it one place left, while the counter carries on to the right. One item gets stepped over for each removal.

It is worse than it looks, because the result is sometimes correct by accident. With [1, 2, 3, 4, 5, 6] the same code gives the right answer, which is exactly how the bug survives testing.

Build a new list with a comprehension, or loop over a copy with items[:] if you must remove in place. Splitting a list covers the related case of processing in chunks.

Changing items through the index

The index is what lets you write back into the list:

prices = [10, 20, 30]

for i, price in enumerate(prices):
    prices[i] = price * 1.1

print("updating each item in place through its index:")
print(" ", [round(p, 2) for p in prices])
print()
print("assigning to `price` instead would do nothing, because it is")
print("only a name for the value, not a slot in the list:")

prices = [10, 20, 30]
for i, price in enumerate(prices):
    price = price * 1.1
print(" ", prices, " <- unchanged")

Output:

up