Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. Python for loop with index, enumerate() explained
Learn

Python for loop with index, enumerate() explained

Use enumerate() to get the index and the item in one loop. The syntax, the start argument, and why range(len()) breaks on half of what you loop over.

July 21, 2026·11 Min read·By The Devpuff team
A Python for loop printing an index next to each item in a list, with enumerate supplying both values

To write a Python for loop with an index, wrap the thing you are looping over in enumerate() and unpack two variables instead of one:

runners = ["Ada", "Linus", "Grace"]

for i, name in enumerate(runners):
    print(i, name)
0 Ada
1 Linus
2 Grace

i is the position, name is the item, and you did not have to manage a counter.

That is the answer. The rest of this post is the parts that trip people up: counting from 1, what enumerate() actually returns, and why the obvious alternative fails on anything that is not a list. Every output shown here came from running the code on Python 3.13.

Why not just use range(len())?

Because it works right up until it does not. This produces identical output:

for i in range(len(runners)):
    print(i, runners[i])
0 Ada
1 Linus
2 Grace

Same result, so what is wrong with it? Two things.

It reads backwards. You are describing positions and then looking items up, when what you meant was "go through the runners". Every read of runners[i] is a small tax.

It only works on things you can index. That is the real problem, and it shows up the moment you loop over something that is not a list, tuple, or string:

letters = {"a", "b", "c"}

for i in range(len(letters)):
    print(letters[i])
TypeError: 'set' object is not subscriptable

A set has a length, so len() is fine and range() is fine. But sets have no positions, so letters[i] is meaningless and Python says so. Generators are worse, because they fail one step earlier:

gen = (n for n in range(3))
print(len(gen))
TypeError: object of type 'generator' has no len()

enumerate() has neither problem. It never indexes anything. It walks the iterable one item at a time and counts as it goes, so it works on lists, tuples, strings, sets, dictionaries, generators, and file objects alike:

gen = (n * n for n in range(4))

for i, value in enumerate(gen):
    print(i, value)
0 0
1 1
2 4
3 9

Note the counter is still counting even though the generator produced its values on demand and there was never a list in memory.

Counting from 1 with the start argument

enumerate() takes a second argument, and it is the one people most often miss:

for place, name in enumerate(runners, start=1):
    print(f"{place}. {name}")
1. Ada
2. Linus
3. Grace

Anything a human reads is usually 1-based: race positions, line numbers, step counts, table rows. start=1 is cleaner than writing i + 1 in three places, and it makes the intent obvious at the top of the loop instead of scattered through the body.

start can be any integer, including a negative one, though "start at 100" is where the good uses run out.

What enumerate actually returns

Not a list. Print it and you get this:

print(enumerate(runners))
<enumerate object at 0x...>

It is a lazy iterator. It produces (index, item) pairs one at a time, on demand, and holds none of them. Loop over a ten million item range and memory does not move.

To see the pairs, ask for them:

print(list(enumerate(runners)))
[(0, 'Ada'), (1, 'Linus'), (2, 'Grace')]

Now the unpacking makes sense. Each item is a two-item tuple, and for i, name in ... splits it into two variables. That is ordinary tuple unpacking, the same thing a, b = 1, 2 does.

Which explains this common surprise:

for item in enumerate(runners):
    print(item)
(0, 'Ada')
(1, 'Linus')
(2, 'Grace')

One variable catches the whole tuple. Nothing is wrong. You just forgot to unpack, and now you are printing pairs.

Looping over other things with an index

A string enumerates by character:

for i, char in enumerate("code"):
    print(i, char)
0 c
1 o
2 d
3 e

A dictionary needs .items(), and then the item is itself a pair, so you nest the unpacking in brackets:

scores = {"ada": 91, "linus": 78}

for i, (name, score) in enumerate(scores.items(), start=1):
    print(i, name, score)
1 ada 91
2 linus 78

Those brackets around (name, score) are required. Without them Python sees three variables and two values, and refuses.

Two lists at once is zip() inside enumerate():

names = ["Ada", "Linus"]
scores = [91, 78]

for i, (name, score) in enumerate(zip(names, scores), start=1):
    print(i, name, score)
1 Ada 91
2 Linus 78

Read it inside out. zip pairs the lists, enumerate numbers the pairs, and the unpacking takes them apart again. This one combination covers most "I need a numbered table" jobs.

A set has no order, so number it after sorting or accept whatever order you get:

letters = {"a", "b", "c"}

for i, letter in enumerate(sorted(letters)):
    print(i, letter)
0 a
1 b
2 c

When the index is the point: changing items in place

There is one job enumerate() does that a plain for loop cannot, and it is the reason the index is worth having at all.

A loop variable is a copy of a reference, so assigning to it changes nothing:

for score in scores:
    score = score * 2   # does nothing to the list

With the index, you can write back:

scores = [10, 20, 30]

for i, score in enumerate(scores):
    scores[i] = score * 2

print(scores)
[20, 40, 60]

Worth saying: for this particular job a list comprehension is shorter and clearer.

scores = [score * 2 for score in scores]

Use the index version when you are updating only some elements, or when the new value depends on the position.

Line numbers, the job enumerate was built for

Numbering lines is where start=1 stops being a nicety and becomes correct, because line 1 is line 1 in every editor, log, and error message ever written.

From a string:

text = "first line\nsecond line\nthird line"

for lineno, line in enumerate(text.splitlines(), start=1):
    print(f"{lineno}: {line}")
1: first line
2: second line
3: third line

From a file, where it matters more, because a file object has no length and cannot be indexed:

with open("notes.txt") as f:
    for lineno, line in enumerate(f, start=1):
        print(lineno, line.rstrip())
1 alpha
2 beta
3 gamma

range(len(f)) is not an option here at all. The file is read lazily, one line at a time, and enumerate() is the only one of the two approaches that can count something it has not finished reading. On a two gigabyte log file that is the difference between working and running out of memory.

.rstrip() is there because each line still carries its newline character.

Where the index and the item disagree

The index counts what enumerate() receives. That sounds obvious until you wrap something around your list, and then it produces three surprises in a row.

Reversing. The items go backwards, the index still goes forwards:

runners = ["Ada", "Linus", "Grace"]

for i, name in enumerate(reversed(runners)):
    print(i, name)
0 Grace
1 Linus
2 Ada

Grace is index 0 here, and index 2 in the original list. If you want the real positions, number first and reverse after:

for i, name in reversed(list(enumerate(runners))):
    print(i, name)
2 Grace
1 Linus
0 Ada

Slicing. Same story. enumerate() sees the slice, not the original:

runners = ["Ada", "Linus", "Grace", "Alan"]

for i, name in enumerate(runners[1:]):
    print(i, name)
0 Linus
1 Grace
2 Alan

Linus is at position 1 in runners and position 0 here. start=1 fixes it, and this is the one case where start is doing arithmetic rather than presentation:

for i, name in enumerate(runners[1:], start=1):
    print(i, name)
1 Linus
2 Grace
3 Alan

Filtering. Numbers come out consecutive even though items were skipped:

runners = ["Ada", "Linus", "Grace"]

for i, name in enumerate(n for n in runners if n != "Linus"):
    print(i, name)
0 Ada
1 Grace

Grace is 1, not 2. If you need the original positions, filter inside the loop instead of before it, so enumerate() still sees every item:

for i, name in enumerate(runners):
    if name == "Linus":
        continue
    print(i, name)
0 Ada
2 Grace

Now Grace is 2, and the gap is the point.

Two errors and one silent bug

Forgetting to unpack, then using the value:

for i in enumerate(runners):
    print(i + 1)
TypeError: can only concatenate tuple (not "int") to tuple

The message names the problem exactly: i is a tuple, not a number. Add the second variable.

Naming the variables in the wrong order does not error at all:

for name, i in enumerate(runners):
    print(name, i)
0 Ada
1 Linus

name holds the number and i holds the name. Python does not know what you meant by those words. Everything downstream is wrong and nothing complains, which makes this worse than the error above.

start is positional, so this works and does not mean "step by 10":

print(list(enumerate(runners, 10)))
[(10, 'Ada'), (11, 'Linus'), (12, 'Grace')]

Counting from 10, one at a time. There is no step argument. If you need one, zip(range(0, 100, 10), items) is the tool.

enumerate in comprehensions

The same unpacking works in comprehensions, which is where it earns its keep for building lookup tables.

Numbering a list of strings:

runners = ["Ada", "Linus", "Grace"]
print([f"{i}. {name}" for i, name in enumerate(runners, start=1)])
['1. Ada', '2. Linus', '3. Grace']

Keeping every other item, decided by position rather than value:

print([name for i, name in enumerate(runners) if i % 2 == 0])
['Ada', 'Grace']

And the genuinely useful one, a name to position lookup:

positions = {name: i for i, name in enumerate(runners)}
print(positions)
print(positions["Grace"])
{'Ada': 0, 'Linus': 1, 'Grace': 2}
2

That dictionary turns "where is Grace in this list?" from a scan into a single lookup, which matters once the list is long and you are asking repeatedly.

For the other direction, index to name, there is a shortcut, because enumerate() already produces exactly the pairs dict() wants:

print(dict(enumerate(runners)))
{0: 'Ada', 1: 'Linus', 2: 'Grace'}

No comprehension needed at all.

Quick reference

You want Write
Index and item for i, x in enumerate(items):
Numbering from 1 for i, x in enumerate(items, start=1):
Index only for i in range(len(items)):
Item only for x in items:
Two lists together for a, b in zip(list_a, list_b):
Two lists, numbered for i, (a, b) in enumerate(zip(a_list, b_list)):
Key, value, and a count for i, (k, v) in enumerate(d.items()):
Every pair, as a list list(enumerate(items))

Three mistakes to skip

  1. Forgetting to unpack. for item in enumerate(x) gives you tuples. You want for i, item in enumerate(x).
  2. Using range(len()) out of habit. It works on sequences and breaks on everything else, and it reads worse even when it works.
  3. Adding an index you never use. If the body never mentions i, drop enumerate() entirely. for name in runners is the better loop.

Why this is the idiomatic answer

enumerate() is a built-in, documented in the Python standard library reference alongside len() and print(). It is not a clever trick or a library import, and reviewers on any Python codebase will expect it.

The deeper reason it is preferred is that it describes intent. range(len(items)) says "produce the numbers 0 to 2, then use each one to look something up". enumerate(items) says "go through the items, and number them as you go". The second is what you meant, it works on things that cannot be indexed, and it removes an entire class of off-by-one error by never asking you to write an index expression at all.

That is the whole argument, and it is why every Python style guide lands in the same place.

Run it yourself

Python is one of those languages where the syntax is easy to read and easy to half-remember, and the difference shows up as a TypeError you have to Google. Running the snippets beats trusting them.

Everything above runs in the browser on Devpuff, no install required, and the Python hub is the place to start. If you are learning loops in general, How Websites Work is worth a read for the wider picture of where the code you write ends up, and Big O Notation Explained covers what happens when a loop inside a loop meets a big list.

Keep reading

More from the blog

Two database tables side by side with lines connecting matching rows, and the joined result set below them
July 21, 2026·9 min readLearn

SQL joins explained, which rows survive and why

INNER, LEFT, RIGHT, FULL and CROSS joins on the same two tables, with the real result rows. Plus why the Venn diagram everyone shows you is misleading.

Read more
A function shown holding on to a variable from the outer function that created it, after that outer function has returned
July 21, 2026·10 min readLearn

JavaScript closures explained with 3 examples

A closure is a function that remembers the variables around it after the function that made them has finished. Three real uses and the classic loop bug.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResources
Support
Help CenterContactStatus
Programs
Frontend Development
Courses
HTML Basics
Learn to Code
Learn JavaScriptLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs MimoDevpuff vs SololearnDevpuff vs freeCodeCampRead All Comparisons
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy