Python For Loop ā The Complete Guide
range(), enumerate(), zip(), for-else, nested loops, and the mutation bug that silently skips items ā explained with real bugs, not textbook definitions.
Last Updated
July 2026
Read Time
28 min
Level
Beginner
What Is a For Loop in Python?
A for loop in Python iterates over the items of any iterable ā a list, a string, a tuple, a dictionary, a set, a file, a range of numbers, basically anything you can walk through one item at a time. That's the definition every tutorial gives. Here's the part that actually matters and that most beginners coming from Java or C never quite absorb: Python's for loop is not a counting loop. It's not for (int i = 0; i < n; i++) wearing different syntax. It's fundamentally a for-each loop, full stop, and everything else ā including counting ā is layered on top of that using helper functions like range() and enumerate().
This distinction genuinely changes how you should think about writing loops in Python. In C or Java, you reach for an index because that's the only tool you have. In Python, reaching for a manual index (i = 0, then while i < len(my_list), then my_list[i], then i += 1) is almost always a sign you're fighting the language instead of using it. Python gives you the actual item directly. Use it.
fruits = ['apple', 'banana', 'mango']
for fruit in fruits:
print(fruit)
print("Loop finished")Output
apple banana mango Loop finishedMy honestly opinionated take here: if I see for i in range(len(my_list)): print(my_list[i]) in a code review, that's an instant comment, no exceptions. It's not wrong, it runs fine, it's just not Python ā it's C written with Python syntax. The direct version, for item in my_list: print(item), is shorter, faster (fewer index lookups), and does exactly the same thing. The only time you actually need the index is when you need the index, and Python has a purpose-built tool for that too ā enumerate(), covered further down.
How a For Loop Actually Works Under the Hood
Every for loop in Python is secretly calling iter() on whatever you're looping over, getting back an iterator object, and then repeatedly calling next() on that iterator until it raises a StopIteration exception. You never see any of this happen ā Python catches the StopIteration for you and quietly ends the loop ā but knowing it's happening explains a lot of behavior that otherwise seems mysterious, especially around generators and why some things can only be looped over once.
fruits = ['apple', 'banana']
# What 'for fruit in fruits:' is actually doing, unrolled manually:
iterator = iter(fruits)
while True:
try:
fruit = next(iterator)
except StopIteration:
break
print(fruit)Output
apple bananaThis is exactly why a generator (something built with yield) can only be looped over once and then it's exhausted ā calling iter() on a generator just returns itself, and once next() has raised StopIteration, there's no way to rewind it. A list, on the other hand, gives you a fresh iterator every time you call iter() on it, which is why you can loop over the same list twice without any issue but looping over an already-consumed generator a second time just does nothing.
For Loop Execution Flow ā Flowchart
Here's the full decision path a for loop actually takes, including the part almost nobody thinks about: the hidden call to iter() right at the start, and the StopIteration exception that ends things quietly behind the scenes.
Code Execution Flow ā from source to output
The detail worth remembering from this diagram: the loop doesn't check anything like a condition on every pass the way a while loop does. It just keeps asking the iterator for the next item until the iterator itself says there isn't one, by raising StopIteration. That's a fundamentally different termination mechanism than a while loop's condition check, and it's exactly why a for loop can never accidentally become infinite the way a while loop can ā as long as the iterable is finite, the loop is guaranteed to end.
The range() Function ā Counting Without an Index Variable
When you genuinely need to repeat something a fixed number of times, or generate a numeric sequence, range() is the tool. It doesn't actually build a list of numbers in memory ā in Python 3, range() returns a lightweight range object that generates numbers on demand as the loop asks for them, which is why range(10_000_000) is instant and uses barely any memory, unlike Python 2's version which built a real list upfront.
for i in range(5):
print(i, end=" ") # 0 1 2 3 4
print()
for i in range(2, 8):
print(i, end=" ") # 2 3 4 5 6 7
print()
for i in range(0, 10, 2):
print(i, end=" ") # 0 2 4 6 8
print()
for i in range(10, 0, -1):
print(i, end=" ") # 10 9 8 7 6 5 4 3 2 1
print()Output
0 1 2 3 4 2 3 4 5 6 7 0 2 4 6 8 10 9 8 7 6 5 4 3 2 1The range() Upper Bound Is Always Exclusive
This is the single most common off-by-one bug beginners write. range(5) gives you 0, 1, 2, 3, 4 ā five numbers, but it never includes 5 itself. If you need to include the endpoint, you have to write range(n + 1). I've watched this exact mistake show up in a loop that was supposed to process invoice IDs 1 through 100 inclusive, written as range(1, 100) instead of range(1, 101) ā silently dropping the very last invoice from a batch job, with no error, no warning, just quietly incomplete output that nobody noticed until someone manually reconciled the totals.
Iterating Over Different Data Structures
A for loop works identically on the surface across every built-in collection type, but what you actually get back on each pass differs ā and dictionaries in particular trip up a lot of beginners.
Lists, Tuples, and Sets
colors = ['red', 'green', 'blue'] # list ā ordered, preserves insertion order
for color in colors:
print(color)
coordinates = (10, 20) # tuple ā same iteration, but immutable
for value in coordinates:
print(value)
unique_ids = {101, 202, 303} # set ā NO guaranteed order
for uid in unique_ids:
print(uid) # order across runs/versions isn't guaranteed to be insertion orderStrings
Looping over a string gives you one character at a time. Simple, but worth stating explicitly, because it means for x in "hi": is looping twice, once for 'h' and once for 'i' ā which connects back to why sequence patterns in match-case deliberately exclude strings from unpacking like a two-element list.
for char in "Python":
print(char, end="-")
# Output: P-y-t-h-o-n-Dictionaries ā The Part Everyone Gets Wrong at Least Once
Looping directly over a dictionary ā for x in my_dict: ā gives you only the keys, not the values, and definitely not key-value pairs. This surprises basically everyone the first time. To get both, you need .items(). Since Python 3.7, dictionaries also guarantee insertion order, which wasn't always true in earlier versions and caught a lot of people out when relying on dict ordering in code meant to also run on Python 3.6 or older.
user = {'name': 'Ashish', 'role': 'admin', 'active': True}
for x in user:
print(x) # prints keys only: name, role, active
for key in user.keys():
print(key) # same as above, explicit
for value in user.values():
print(value) # Ashish, admin, True
for key, value in user.items():
print(f"{key}: {value}") # the version you almost always actually wantOutput
name role active name: Ashish role: admin active: Trueenumerate() ā Getting the Index Without Manual Counting
This is the function that replaces the C-style for i in range(len(my_list)) anti-pattern entirely. enumerate() wraps any iterable and gives you back (index, item) pairs, so you get the item directly AND its position, without ever writing a manual counter.
tasks = ['Write report', 'Review PR', 'Deploy build']
for index, task in enumerate(tasks):
print(f"{index}: {task}")
# Start counting from 1 instead of 0 ā common for user-facing display
for index, task in enumerate(tasks, start=1):
print(f"Task #{index}: {task}")Output
0: Write report 1: Review PR 2: Deploy build Task #1: Write report Task #2: Review PR Task #3: Deploy buildThe start=1 parameter is genuinely underused. Anytime you're building something for a human to read ā a numbered task list, an invoice line-item count, a leaderboard ā starting at 1 instead of manually writing index + 1 everywhere in your f-strings is cleaner and removes an easy place to introduce an off-by-one mismatch between what you print and what you actually mean.
zip() ā Looping Over Multiple Sequences Together
zip() pairs up items from two or more iterables, position by position, letting you loop over them together in a single for loop instead of indexing into each one separately. It's the tool you reach for whenever you've got parallel lists ā names and scores, dates and values, headers and row data pulled from a CSV.
names = ['Ashish', 'Priya', 'Rahul']
scores = [88, 92, 79]
for name, score in zip(names, scores):
print(f"{name} scored {score}")Output
Ashish scored 88 Priya scored 92 Rahul scored 79zip() Silently Stops at the Shortest Iterable
This is a genuinely sneaky one. If your input lists don't have matching lengths, zip() doesn't raise an error ā it just quietly stops as soon as the shortest one runs out, silently dropping the extra items from the longer lists with zero warning. If names has 5 entries and scores only has 3, you'll only get 3 pairs back, and the missing 2 names just vanish from the loop without a trace. If mismatched lengths should actually be an error in your case (a real bug, not expected behavior), use zip(names, scores, strict=True), added in Python 3.10, which raises a ValueError: zip() argument 2 is shorter than argument 1 the moment the lengths don't line up ā genuinely one of the more useful small additions to the language in recent years, and criminally underused.
names = ['Ashish', 'Priya', 'Rahul', 'Deepak']
scores = [88, 92, 79]
# Silent data loss ā 'Deepak' just disappears, no warning:
for name, score in zip(names, scores):
print(name, score)
# Fails loudly instead, which is usually what you actually want:
for name, score in zip(names, scores, strict=True):
print(name, score)
# ValueError: zip() argument 2 is shorter than argument 1The for-else Clause
Just like while loops, for loops in Python support an else clause with the exact same behavior ā it runs only if the loop completes fully without hitting a break. The naming confuses people just as much here, but the classic use case is identical: search-and-report patterns, distinguishing between "found it and stopped early" and "checked everything, found nothing."
def find_prime_factor(n):
for i in range(2, n):
if n % i == 0:
print(f"{n} is divisible by {i}")
break
else:
print(f"{n} is a prime number")
find_prime_factor(15)
find_prime_factor(13)Output
15 is divisible by 3 13 is a prime numberWithout else, you'd write a separate flag variable ā set is_prime = True before the loop, flip it to False right before the break, then check the flag afterward. The for-else version removes that flag entirely, at the cost of readability for anyone unfamiliar with the construct. My honest recommendation: use it, but only in code where the team already knows the pattern, or leave a short comment the first time ā it's genuinely elegant once understood, genuinely confusing on first encounter.
Nested For Loops
A for loop can contain another for loop, running the inner loop to completion on every pass of the outer one. This is the natural way to walk a 2D grid, build a multiplication table, or compare every item in one list against every item in another.
for row in range(1, 4):
for col in range(1, 4):
print(f"{row}x{col}={row * col}", end=" ")
print()Output
1x1=1 1x2=2 1x3=3 2x1=2 2x2=4 2x3=6 3x1=3 3x2=6 3x3=9The Time Complexity Trap
Nested loops are where innocent-looking code quietly becomes an O(n²) problem. A single loop over 10,000 items is fast. A nested loop comparing 10,000 items against another 10,000 items is 100 million operations, and that's the exact shape of code that works fine in a local test with 50 sample rows and then times out in production against a real dataset. If you find yourself nesting a loop specifically to check "does this item exist in the other list," convert the second list to a set first and use a plain membership check instead ā that alone turns an O(n²) search into an O(n) one, which is often the difference between a script finishing in seconds versus minutes.
The Mutation-While-Iterating Bug (The One That Silently Skips Items)
This is, in my experience, the single most dangerous for-loop bug in Python, precisely because it doesn't crash. It doesn't raise an exception. It just quietly processes the wrong items, and you only find out later when the output looks slightly off. The rule: never modify a list's length while you're iterating over it directly.
# BUGGY ā silently skips items, no error raised
numbers = [1, 2, 3, 4, 5, 6]
for n in numbers:
if n % 2 == 0:
numbers.remove(n)
print(numbers) # [1, 3, 5] ... looks right by luck, but let's see why it's actually brokenOutput
[1, 3, 5]That particular example happens to produce the "expected-looking" result, which is exactly what makes this bug so dangerous ā it can pass a quick manual check while still being fundamentally broken. Here's what's actually happening: a for loop over a list tracks position by index internally. When you remove an item, everything after it shifts left by one position, but the loop's internal index keeps advancing normally ā so it ends up skipping the item that just slid into the position it already passed. Try it with a list where two even numbers sit next to each other and the bug becomes obvious.
# Now the bug is obvious ā consecutive even numbers
numbers = [2, 4, 6, 8, 10]
for n in numbers:
if n % 2 == 0:
numbers.remove(n)
print(numbers) # [4, 8] <- WRONG, should be [] since all are evenOutput
[4, 8]Every one of those numbers is even, so the fully correct output should be an empty list. Instead, 4 and 8 survive, purely because of the index-shifting problem ā after removing 2, everything shifts left, the loop's next index lands on what used to be index 2 (6) but is now actually 8 after the shift, and 4 gets skipped entirely. This is exactly the kind of bug that survives a quick smoke test and then produces subtly wrong output in production for weeks. The fix is simple: iterate over a copy, or build a new list instead of mutating the original in place.
numbers = [2, 4, 6, 8, 10]
# Fix 1 ā iterate over a copy using slicing, mutate the original safely
for n in numbers[:]:
if n % 2 == 0:
numbers.remove(n)
print(numbers) # []
# Fix 2 ā build a new list instead of mutating (usually the cleaner approach)
numbers = [2, 4, 6, 8, 10]
numbers = [n for n in numbers if n % 2 != 0]
print(numbers) # []Output
[] []The same category of bug applies to dictionaries too ā mutating a dict's keys while iterating over it raises an immediate, loud RuntimeError: dictionary changed size during iteration. Honestly, I consider the dictionary version the more developer-friendly outcome, precisely because it fails loudly instead of silently producing wrong data. Lists deserve the same treatment conceptually, they just don't get it, which is exactly why this is worth memorizing rather than relying on Python to catch it for you.
For Loop vs List Comprehension ā When to Use Which
A list comprehension is, structurally, a for loop that builds a list, compressed onto one line. [x * 2 for x in numbers] does the same job as a plain for loop appending to an empty list, just faster to write and, in most cases, faster to run too, since comprehensions avoid the repeated attribute lookup for .append() on every iteration.
My opinionated take on this one: list comprehensions are wonderful right up until they need a second condition or a nested loop, and then people keep cramming logic into them anyway because they've decided comprehensions are "more Pythonic." A three-level nested comprehension with two conditions is not more Pythonic than a plain for loop ā it's a puzzle the next reader has to solve. If a comprehension doesn't fit comfortably on one line without mental gymnastics, write the for loop instead.
Try It Yourself ā For Loop Playground
Edit the snippet below ā swap the list, add enumerate(), or try zip() with a second list of your own.
students = ['Ashish', 'Priya', 'Rahul']
marks = [88, 92, 79]
for index, (name, score) in enumerate(zip(students, marks), start=1):
status = "Pass" if score >= 40 else "Fail"
print(f"{index}. {name} ā {score} ({status})")Output
1. Ashish ā 88 (Pass) 2. Priya ā 92 (Pass) 3. Rahul ā 79 (Pass)Practice This Code ā Live Editor
Where For Loops Actually Show Up in Real Code
- ā¶
Processing rows from a CSV or database query ā for row in cursor.fetchall(): is the standard shape for handling query results one record at a time.
- ā¶
Batch API requests ā for user_id in user_ids: call_api(user_id) is the common pattern for hitting an endpoint once per item in a known list.
- ā¶
Building numbered reports or invoices ā enumerate(items, start=1) generates the line numbers a client-facing PDF or email actually needs.
- ā¶
Comparing two parallel datasets ā zip(expected, actual) pairs up test results with expected values in a testing script, one of the cleanest real uses of zip().
- ā¶
Django template loops ā {% for product in products %} in Django templates is literally a for loop rendering HTML, following the exact same iteration rules as Python code.
- ā¶
Data cleaning pipelines ā filtering, transforming, and validating rows of a dataset almost always starts life as a for loop before potentially becoming a list comprehension or a pandas vectorized operation.
- ā¶
File processing ā for line in open('logfile.txt'): reads a file lazily, one line at a time, without loading the entire file into memory at once.
Do's and Don'ts With For Loops
Python For Loop ā Interview Questions
Practice Questions ā Test Your Understanding
1. What does this loop print? for i in range(3, 0, -1): print(i)
Easy2. What does for x in {'a': 1, 'b': 2}: print(x) actually output?
Easy3. Why does this code produce a wrong result? nums = [1, 2, 2, 3]; for n in nums: if n == 2: nums.remove(n) ā expected output [], actual output?
Hard4. What's the output of: for name, age in zip(['A', 'B', 'C'], [25, 30]): print(name, age)?
Medium5. What does list(enumerate(['x', 'y', 'z'], start=5)) return?
Medium6. Does the else block execute in this loop? for i in range(5): if i == 10: break else: print('completed')
Medium7. What error does zip(['a','b','c'], [1,2], strict=True) raise, and why?
Hard8. Convert this for loop into a list comprehension: result = []; for x in range(10): if x % 3 == 0: result.append(x * x)
HardConclusion ā Mastering the For Loop
The for loop is the tool you'll reach for more than any other single construct in Python, and it rewards you the moment you stop treating it like a counting loop from another language. Iterate over items directly. Reach for enumerate() only when you actually need the index, zip() only when you're genuinely walking two sequences together, and a comprehension only when the logic fits comfortably on one clear line.
If there's one habit worth carrying forward from this entire guide, it's this: the moment you're about to call .remove(), .append(), or .pop() on the same list you're currently looping over, stop and ask whether you should be iterating over a copy or building a new list instead. That single pause catches one of the sneakiest, most silent bugs in everyday Python ā the kind that doesn't crash, doesn't warn you, and just quietly gives you wrong output until someone notices the numbers don't add up.
Next, pair this with a solid grip on generators and the yield keyword ā once you understand that a for loop is really just repeatedly calling next() under the hood, generators stop feeling like a separate, mysterious topic and start feeling like the natural next step in the exact same mental model you already have.