Python for loop tutorial with range(), enumerate(), and zip(). Iterate over lists, strings, dictionaries, and multiple sequences with tested examples.
“Readability counts.” A good Python for loop reads like a plain sentence: for each student in the class, print the name.
Tim Peters, The Zen of Python (PEP 20)
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 15 minutes
A while loop is the “keep going until something changes” tool. A Python for loop is the “do this once for each item” tool. And here is the thing that trips up people coming from C or Java: the Python for loop does not count. It iterates. You hand it a collection, and it walks through every item, one at a time, in order. That is the focus of this Python for loop tutorial.
Think of a teacher with an attendance register. She does not say “go to row 0, then row 1, then row 2.” She just reads down the list of names and calls each one. That is exactly how a Python for loop feels. In C you write for(int i=0; i<n; i++) and spend your energy babysitting an index. In Python you write for item in collection: and you get handed each item directly. You rarely need the index at all. When you genuinely do, enumerate() hands it to you cleanly, and we will get to that below.
Table of Contents
Iterating Over Sequences
The diagram shows what a Python for loop is really doing behind the curtain. It calls iter() on your collection to get an iterator, then keeps calling next() to pull out one item at a time, until the iterator raises StopIteration to say “that is the last one.” Picture a vending machine that drops one snack each time you press the button, until a little light comes on to tell you the row is empty.
That press, grab, repeat rhythm is the iterator protocol, and it is the reason the same for loop works on strings, lists, files, and any object you write yourself that knows how to hand out items. You will lean on this idea later when you build your own iterable classes.
📄 basic_for.py: for loops work on anything iterable
# List
team = ["Rahul", "Niranjan", "Viraj", "Pravin"]
for member in team:
print(f"Team member: {member}")
# String: iterates one character at a time
for char in "Python":
print(char, end=" ")
print()
# Tuple
coordinates = (10, 20, 30)
for coord in coordinates:
print(f"Coordinate: {coord}")
▶ Output
Team member: Rahul Team member: Niranjan Team member: Viraj Team member: Pravin P y t h o n Coordinate: 10 Coordinate: 20 Coordinate: 30
What happened here: The same three-line loop pattern handled a list, a string, and a tuple without changing at all. That is the whole point. A for loop does not care what kind of collection you give it, as long as the collection knows how to hand out its items one by one. With the string, each item is a single character, which is why "Python" printed as P y t h o n. The end=" " argument just tells print to put a space after each piece instead of starting a new line.
range(): When You Need Numbers
Sometimes you do not have a list to walk over. You just want to repeat something a fixed number of times, or count through a span of numbers. That is the job of range(). Think of it like the token machine at a bank counter: it hands out the next number every time you ask, in order, and the loop serves each number in turn.
📄 range_examples.py: generating number sequences
# range(stop): 0 up to stop minus 1
for i in range(5):
print(i, end=" ")
print()
# range(start, stop): start up to stop minus 1
for i in range(2, 7):
print(i, end=" ")
print()
# range(start, stop, step)
for i in range(0, 20, 3):
print(i, end=" ")
print()
# Counting backwards
for i in range(5, 0, -1):
print(i, end=" ")
print()
▶ Output
0 1 2 3 4 2 3 4 5 6 0 3 6 9 12 15 18 5 4 3 2 1
What happened here: range() is lazy. It does not build a giant list in memory first; it just produces the next number when the loop asks for it, which is why range(1_000_000) is cheap. Notice that the stop value itself is never included: range(5) gives you 0, 1, 2, 3, 4 and never reaches 5. The three-argument form range(start, stop, step) gives you full control, and a negative step like range(5, 0, -1) counts down, from 5 to 1. The exclusive stop feels odd at first, but it is handy: range(len(team)) lines up perfectly with the valid index positions of a list.
enumerate(): Index Plus Value Together
Sooner or later you will want the position number along with the item. Say you have a class list with students named Anvi and Anvay at the top, and you want to print “1. Anvi, 2. Anvay.” The tempting move is range(len(list)) and then index back in. Resist it. enumerate() hands you both the counter and the item together. It is like a host at a restaurant who calls out “table 1, the Sharma family; table 2, the Patil family,” reading off the number and the name in one breath.
📄 enumerate_examples.py: the Pythonic way to get indices
students = ["Anvi", "Anvay", "Aviraj", "Aditi"]
# ❌ The non-Pythonic way
for i in range(len(students)):
print(f"{i}: {students[i]}")
print("---")
# ✅ The Pythonic way
for index, name in enumerate(students):
print(f"{index}: {name}")
print("---")
# Start counting from 1
for rank, name in enumerate(students, start=1):
print(f"Rank {rank}: {name}")
▶ Output
0: Anvi 1: Anvay 2: Aviraj 3: Aditi --- 0: Anvi 1: Anvay 2: Aviraj 3: Aditi --- Rank 1: Anvi Rank 2: Anvay Rank 3: Aviraj Rank 4: Aditi
What happened here: Look at the first two blocks of output. They are identical, but the second loop is far easier to read, and it does not poke back into the list with students[i]. On each pass enumerate() hands you a pair, the position and the name, and we unpack it straight into index, name. The third loop shows the one option worth remembering: start=1 makes the counter begin at 1 instead of 0, which is exactly what you want for human-facing rankings or numbered lists.
zip(): Parallel Iteration
What if the data you need is spread across two or three separate lists, like names in one and scores in another? You want to walk all of them in step, the first of each, then the second of each, and so on. That is zip(). The name fits: it works like the zipper on a jacket, pulling the left teeth and the right teeth together one pair at a time.
📄 zip_examples.py: walking several sequences at once
names = ["Rahul", "Niranjan", "Viraj"]
scores = [95, 88, 92]
cities = ["Pune", "Mumbai", "Nashik"]
# Pair two lists
for name, score in zip(names, scores):
print(f"{name}: {score}")
print("---")
# Pair three lists
for name, score, city in zip(names, scores, cities):
print(f"{name} from {city}: {score}")
# zip stops at the shortest list, no error raised
short = [1, 2]
long = [10, 20, 30, 40]
for a, b in zip(short, long):
print(f"{a} + {b} = {a + b}")
▶ Output
Rahul: 95 Niranjan: 88 Viraj: 92 --- Rahul from Pune: 95 Niranjan from Mumbai: 88 Viraj from Nashik: 92 1 + 10 = 11 2 + 20 = 22
What happened here: zip() lined up the lists by position and let us unpack each pair (or triple) right in the loop header. The last block shows the one catch worth knowing: when the lists are different lengths, zip() quietly stops at the shortest one. It paired 1 with 10 and 2 with 20, then stopped, so the 30 and 40 were never touched. No error, no warning. If you actually want it to run to the longest list and fill the gaps, reach for itertools.zip_longest().
Iterating Over Dictionaries
Dictionaries hold pairs, a key and its value, so looping over them has a small twist. By default a for loop over a dict walks the keys. When you want the values, or both together, you ask for them by name. Think of a contacts app: by default you scroll the names, but you can also pull up just the numbers, or each name next to its number.
📄 dict_iteration.py: keys, values, and items
scores = {"Pravin": 88, "Sardar": 95, "Prathamesh": 72}
# Keys (default)
for name in scores:
print(f"Student: {name}")
# Values
for score in scores.values():
print(f"Score: {score}")
# Both at once, the most useful pattern
for name, score in scores.items():
grade = "Pass" if score >= 60 else "Fail"
print(f"{name}: {score} ({grade})")
▶ Output
Student: Pravin Student: Sardar Student: Prathamesh Score: 88 Score: 95 Score: 72 Pravin: 88 (Pass) Sardar: 95 (Pass) Prathamesh: 72 (Pass)
What happened here: Looping straight over the dict gave us the keys (the student names) in the order they were added. .values() gave us just the scores. The real workhorse is .items(): it hands you the key and the value together, so we could unpack them into name, score and work out a Pass or Fail grade in the same pass. The order is not random, by the way. Since Python 3.7 a dict remembers the order you inserted things, so the names come out in the order you typed them.
Nested For Loops
A loop can live inside another loop. For every single item the outer loop visits, the inner loop runs all the way through. It is like a clock: the minute hand makes one full circle for each single step of the hour hand. That is exactly how you build a grid, a multiplication table, or a board of rows and columns.
📄 nested.py: loops inside loops
# Multiplication table (3x3)
for i in range(1, 4):
for j in range(1, 4):
print(f"{i}×{j}={i*j:2}", end=" ")
print()
▶ Output
1×1= 1 1×2= 2 1×3= 3 2×1= 2 2×2= 4 2×3= 6 3×1= 3 3×2= 6 3×3= 9
What happened here: The outer loop sets i to 1, then the inner loop runs j through 1, 2, 3 and prints that whole row. Then the outer loop moves to i = 2 and the inner loop runs all over again, and so on. So the inner body runs nine times in total, three times for each of the three outer passes. The :2 inside the f-string pads each product to two spaces wide so the columns line up, and the bare print() after the inner loop drops us onto a new line for the next row.
The Catch: Modifying a List While Iterating
Here is the trap that bites almost everyone once. If you delete items from a list while you are looping over it, the loop quietly skips elements. It does not crash, which is what makes it so sneaky. Imagine ticking names off a register while someone keeps pulling rows out from under your finger: you slide down to the next row, but a name just got yanked into the spot you already passed, so you never read it.
🚫 Dangerous: removing items while looping over the same list
numbers = [1, 2, 2, 3]
for n in numbers:
print("checking", n)
if n % 2 == 0:
numbers.remove(n) # BAD: the loop now skips an item
print("result:", numbers)
▶ Output
checking 1 checking 2 checking 3 result: [1, 2, 3]
What happened here: Look at what got checked: 1, then 2, then 3. The second 2 was never checked at all. When we removed the first 2, every later item shifted left by one position, but the loop had already moved its internal counter forward, so it jumped clean over that second 2. The result still contains an even number, which is the exact opposite of what we wanted. The output is not random, it is just wrong, and that quiet wrongness is what makes this bug so easy to ship.
✅ Safe: build a new list instead of editing the one you are looping
numbers = [1, 2, 2, 3] odds = [n for n in numbers if n % 2 != 0] print(odds) # always correct, nothing is skipped
▶ Output
[1, 3]
What happened here: The list comprehension reads the original numbers without touching it and builds a brand new list of just the odd values. Nothing shifts under your feet while you loop, so nothing gets skipped. If you would rather keep using a plain for loop, the other safe trick is to loop over a copy with for n in numbers[:]: and remove from the original. The golden rule: never edit the same list you are looping over.
Common Mistakes
The classic one: using range(len()) when you don’t need the index
🚫 Non-Pythonic
names = ["Rahul", "Viraj"]
for i in range(len(names)):
print(names[i])
✅ Pythonic
names = ["Rahul", "Viraj"]
for name in names:
print(name)
Why: Both versions print the same two names, but the first one makes you do extra work for nothing. It is like noting down the seat number of every guest at a wedding and then walking to each seat to read the name card, when you could simply walk down the row and greet each person directly. range(len(names)) builds a sequence of positions, then you turn around and index back into the list with names[i] just to get the item you could have had for free. Loop over the list directly and Python hands you each name. Only fall back to indices when you genuinely need the position number, and even then reach for enumerate(), not range(len(x)).
Best Practices
- DO iterate directly:
for item in collection: - DO use
enumerate()when you need indices - DO use
zip()for parallel iteration - DO use
.items()for dictionary key-value pairs - DON’T use
range(len(x))when direct iteration works - DON’T modify a list while iterating over it
Conclusion
The whole Python for loop idea fits in one line: it walks over items, not index numbers. From there, three small tools cover almost everything you will ever need. range() gives you a sequence of numbers when you have no list to walk. enumerate() gives you the position and the item together. zip() walks several lists side by side. Master those three, loop directly instead of reaching for range(len(x)), and never edit a list while you are looping over it. Do that, and your loops will read like plain sentences.
Next up: Loop Control, where you meet break, continue, pass, and the surprising else clause that loops can carry. And if you want to browse every post in this series, from absolute basics to AI/ML projects, visit the Python + AI/ML tutorial series home.
Practice Exercises
- Exercise 1: Calculate the sum of 1 to 100 with a for loop.
- Exercise 2: Use
enumerate()to print each character with its index. - Exercise 3: Generate all 2-letter combinations from “abc” with nested for loops.
Frequently Asked Questions
What is the difference between for and while loops in Python?
for loops iterate over a sequence (list, string, range, etc.) and stop when the sequence is exhausted. while loops repeat as long as a condition is True. Use for when you know the items to process; use while when you don’t know how many iterations are needed.
How does range() work in Python?
range(stop) generates integers from 0 to stop-1. range(start, stop) generates from start to stop-1. range(start, stop, step) adds a step size. range() is lazy, so it generates numbers on demand without ever building a list in memory.
What is enumerate() used for in Python?
enumerate(iterable) returns pairs of (index, value) as you iterate. It replaces the pattern for i in range(len(list)): list[i] with the cleaner for i, item in enumerate(list):. Use start=1 to begin counting from 1 instead of 0.
How does zip() handle lists of different lengths?
zip() stops at the shortest iterable. If you zip [1, 2, 3] with [10, 20], you get [(1, 10), (2, 20)] and the third element is silently dropped. Use itertools.zip_longest() to pad shorter sequences with a fill value.
Can I modify a list while iterating over it?
Technically yes, but it causes bugs. Removing items during iteration shifts the later items left while the loop counter moves right, so the loop skips elements. Instead, iterate over a copy (for item in list[:]:) or build a new list using a list comprehension.
Interview Questions on Python For Loops
The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.
Q: How is a Python for loop different from a for loop in C or Java?
In C or Java, the loop manages a counter: you initialize i, test it, and increment it yourself. A Python for loop iterates directly over the items of any iterable, so each element is handed to you and there is no index to babysit. If you do need the position, you wrap the iterable in enumerate() instead of managing a counter manually.
Q: You write for name, score in scores: over a dictionary and Python raises “ValueError: too many values to unpack.” What went wrong?
Looping over a dictionary directly yields only the keys, so Python tried to unpack each key string into two variables and failed. The fix is for name, score in scores.items():, which yields key-value pairs that unpack cleanly. It is a classic slip because for name in scores: works fine on its own, which hides the fact that values were never in play.
Q: What actually happens under the hood when Python runs for item in collection:?
Python calls iter(collection) to get an iterator, then repeatedly calls next() on it, assigning each returned value to item and running the body. When the iterator raises StopIteration, the loop ends quietly. This iterator protocol is why the same loop syntax works on lists, strings, files, generators, and any custom class that implements it.
Q: A code review flags every for i in range(len(items)) in your pull request. How do you refactor, and when do you genuinely need an index?
When you only use the values, loop directly with for item in items:. When you need the position too, use for i, item in enumerate(items):, which gives both without indexing back into the list. The one honest use for an index is writing back into the list, like items[i] = new_value, and even there enumerate() supplies the i more readably than range(len(items)).
Q: After for ch in "dal": finishes, what does ch hold? What if the string had been empty?
The loop variable survives the loop and keeps its last value, so ch is "l". Python does not scope the variable to the loop body. If the iterable had been empty, the body would never run, the variable would never be assigned, and using it afterwards would raise a NameError unless it was defined earlier.
Q: In for i in range(3): containing for j in range(4):, how many times does the inner body run, and why does that matter?
Twelve times: the inner loop runs all four of its passes for each of the three outer passes, so the total is 3 times 4. This multiplication is exactly why nested loops get expensive fast; two nested loops over a list of 10,000 items mean 100 million inner iterations. In interviews, spotting that a nested loop can be replaced by a dictionary lookup or a set is a common follow-up.
Reference: the complete, always-current details live in the official Python documentation.
Related Posts
Previous: Python: While Loops, Counting, Sentinel, Infinite Patterns
Next: Python Loop Control: break, continue, pass, else on Loops
Series Home: Python + AI/ML Tutorial Series

No comment