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.

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.

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.

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

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]

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