Python for loop — Silent Data Loss from List Modification
47% of flagged records persisted after a for loop cleanup due to in-place list modification — avoid this with list comprehensions or iterating a copy.
- A for loop visits every item in an iterable and runs the same block of code for each — Python handles counting automatically
- range(n) starts at 0 and excludes n — range(5) gives 0,1,2,3,4, never 5
- enumerate() gives you both index and value — use it instead of a manual counter
- zip() pairs items from two sequences together — use it instead of range(len()) when processing two lists in lockstep
- break exits the loop; continue skips the current iteration; else runs only if no break occurred
- Modifying a list while looping over it silently skips items — loop over a copy or build a new list
- List comprehensions are faster and safer than a for loop that builds a new list — prefer them when the body is a single expression
- Biggest trap: the for...else clause runs when the loop completes WITHOUT break, not when it breaks
Imagine you have a bag of 10 apples and you want to check each one for bruises. You don't write a separate rule for apple 1, apple 2, apple 3 — you just say 'for every apple in the bag, do this check.' That's exactly what a for loop does in Python. It lets you write one instruction and automatically repeat it for every item in a collection, without you having to count how many items there are or manually keep track of where you are.
Every real program does repetitive work. A weather app checks temperatures for 365 days. A music app loads every song in your library. An online store applies a discount to every item in your cart.
The for loop solves this elegantly. You write an action once and Python automatically repeats it for every item in a collection — advancing through the sequence, tracking position, and stopping at the end, all without you doing anything extra.
I've reviewed a lot of Python code over the years — across data pipelines, API services, and scrappy internal tools — and for loop misuse shows up more than almost any other category of bug. Usually it's subtle: a list modified mid-iteration, a zip() silently swallowing records, a for...else clause that does the opposite of what the author intended. Most of these issues don't crash — they just produce wrong results quietly, which is worse.
By the end of this article you'll understand the for loop syntax properly, know how to iterate over lists, strings, dictionaries, ranges, and multiple sequences at once with zip(), use enumerate() the way Pythonistas actually use it, write list comprehensions instead of accumulator loops, and recognise the mistakes that trip up nearly every beginner — including a few that experienced developers still walk into on a bad day.
What a for Loop Actually Does — The Core Idea
A for loop says: 'take this collection of things, visit each one in order, and run the same block of code for each.' Python handles all the counting and moving-forward for you. You just describe what to DO on each visit.
The collection you loop over can be a list of names, a string of characters, a range of numbers — almost anything that holds multiple items. Python calls these 'iterables', but you can think of them as 'anything you can step through one piece at a time.'
The basic shape of a for loop is always the same: the word for, then a variable name you choose (this is your 'current item' holder), then in, then the collection, then a colon. Everything indented below that runs once per item. The indentation is not optional — Python uses it to know which lines belong inside the loop and which don't.
Beyond the syntax, it's worth understanding what Python is actually doing under the hood. When you write for planet in planets, Python calls iter(planets) to get an iterator object, then calls next() on it repeatedly until it raises a StopIteration exception — at which point the loop ends cleanly. You can do this manually yourself to see exactly what the loop sees step by step. This also explains why you can loop over strings, files, generators, and custom objects — anything that implements __iter__ and __next__ participates in the same protocol. That's not implementation detail trivia; it's the mental model that makes debugging iterator errors actually tractable.
- Python calls
iter()on the collection to get an iterator object - Each iteration calls
next()to get the next item — StopIteration signals the end and the loop exits cleanly - The loop variable is assigned fresh on each iteration — it does not carry state between rounds
- Any object that implements __iter__ and __next__ can be looped over — not just lists
- Indentation defines the loop body — no braces needed, Python enforces structure through layout
iter() and next() is what separates engineers who debug iterator errors confidently from engineers who just add print statements and hope.iter() and next() makes debugging iterator errors straightforward instead of mysterious.for item in my_list — direct iteration is cleanest and most readablefor _ in range(n) — underscore signals the variable is intentionally unusedfor i, item in enumerate(my_list) — not a manual counter variablefor key, value in my_dict.items() — direct dict iteration only gives keysLooping Over Numbers with range() — Your Most-Used Tool
Most beginners need to repeat something a set number of times — run a countdown from 10, process 100 items, print a multiplication table. For that, Python gives you . It generates a sequence of numbers on demand without storing them all in memory at once.range()
range(5) gives you 0, 1, 2, 3, 4 — five numbers, starting at zero. This trips up almost every beginner at least once: range counts from zero by default, and the number you pass in is NOT included in the output. Think of it as 'up to but not including 5.'
range(start, stop) lets you control where counting begins. range(1, 6) gives 1, 2, 3, 4, 5. range(start, stop, step) adds a step size — range(0, 10, 2) gives you every even number from 0 to 8. You can count backwards with a negative step: range(5, 0, -1) counts down from 5 to 1.
One thing worth internalising early: is a lazy object in Python 3. It does not generate all the numbers upfront and store them in a list. It calculates each number on demand as the loop requests it. range()range(1_000_000) uses a few dozen bytes of memory regardless of how large the range is. This is why you should never write list(range(n)) just to loop over numbers — you're forcing Python to materialise the entire sequence into memory for no reason. The range object already supports indexing, slicing, and membership testing without being converted to a list.
range(), and it produces off-by-one errors that are genuinely annoying to track down. range(5) gives you [0, 1, 2, 3, 4] — five values, but none of them is 5. If you need to print 1 through 5, use range(1, 6). A good mental model: the stop value is a wall the counter hits but never crosses. The memory comparison in the code above also shows why you should never write list(range(n)) just to loop — range is already lazy and memory-efficient on its own, and it supports indexing and membership tests without being materialised.len(), slicing, and 'in' membership checks without conversion — so there is almost never a reason to call list() on it.range() directly for looping. Only convert to list if you genuinely need multiple independent passes or need to serialize the sequence.Looping Over Strings, Dictionaries, and Using enumerate()
A string in Python is a sequence of characters and you can loop over it exactly the same way you loop over a list. Python hands you one character at a time. This is useful for tasks like counting vowels, reversing text, checking for special characters, or validating a pattern character by character.
Dictionaries work a little differently. When you loop over a dictionary directly, you get its keys — just keys, nothing else. To get both keys and values together in one pass, use .items(). It hands you a (key, value) tuple each round, which you unpack by naming two variables after for. If you only need the values, .values() does that directly. If you only need keys, direct iteration and .keys() are equivalent — .keys() is slightly more explicit and communicates intent to whoever reads the code next.
Here is a pattern change that makes a real difference in code quality: . Often you need both the item AND its position number while looping. Without enumerate, a lot of developers create a separate counter variable before the loop and increment it manually on every iteration — three lines where one would do, and a manual increment that's easy to misplace or forget. enumerate()enumerate(collection) hands you a (index, item) tuple on each pass. You unpack it with two names after for. The start parameter lets you control the starting number, which is handy when displaying positions to users who expect to start counting at 1.
One pattern worth calling out explicitly because it shows up constantly in code reviews: for i in range(len(my_list)): followed by my_list[i] inside the loop. This is the C and Java index loop imported into Python. It works, but it fights the language. It breaks on iterables that don't have a , it introduces manual index arithmetic that can be wrong, and it obscures what the code is actually doing. len() is the direct replacement — it works on any iterable, handles the arithmetic for you, and reads as plain English.enumerate()
counter = 0 before a loop and counter += 1 inside it, stop — that's exactly what enumerate() exists to replace. Similarly, for i in range(len(my_list)): followed by my_list[i] is a signal to switch to enumerate(). It's cleaner, less prone to off-by-one errors, and crucially, it works on any iterable — not just lists that support len(). The start parameter is the detail most people miss: enumerate(items, start=1) gives you 1-based positions without any arithmetic.enumerate() for indexed iteration over any sequence, dict.items() for key-value iteration. Both add negligible overhead over direct iteration.len()) pattern — always prefer it when you need an index.enumerate() handles for free.for item in my_list — simplest and most readable formfor i, item in enumerate(my_list) — never use range(len())for key in my_dict — direct iteration gives keys by defaultfor key, value in my_dict.items() — .items() returns (key, value) tuplesLooping Over Two Sequences at Once with zip()
One of the most common loop patterns in real code is processing two related lists in lockstep — pairing names with scores, pairing database keys with fetched values, pairing timestamps with readings. The wrong way is for i in range(len(names)): name = names[i]; score = scores[i]. The right way is .zip()
takes two or more iterables and pairs their items together, one from each, yielding a tuple per step. You unpack the tuple by naming two variables after zip()for. When the shorter iterable runs out, stops — no error, no warning, just stops. This is the right behaviour when you know your lists are the same length, but it becomes a silent data loss bug when they differ. If your two sequences might have different lengths and you need all items from both, use zip() from the standard library, which fills in a configurable default value for the shorter sequence.itertools.zip_longest()
Like range(), zip() is lazy in Python 3. It doesn't build a list of tuples upfront — it generates one pair at a time on demand. zip(list_a, list_b) on two million-item lists uses constant memory because it only ever holds one pair in flight.
also makes code self-documenting in a way that index-based loops can't match. When someone reads zip()for name, score in zip(names, scores), the intent is immediately obvious and the pairing is explicit in the code itself. The index-based equivalent requires the reader to mentally trace that names[i] and scores[i] are intentionally paired — an extra cognitive step that zip() eliminates entirely.
One bonus pattern that comes up often: building a dictionary from two parallel lists. dict(zip(keys, values)) is the idiomatic one-liner, and it reads clearly once you know the pattern.
zip() stops as soon as the shorter one runs out — no error, no warning, no indication that anything was skipped. The extra items from the longer list are silently gone. This is correct behaviour when you know your lists match, but it becomes a quiet data loss bug when they don't. In data pipelines, this is particularly insidious because the output looks valid — it just has fewer rows than it should. If there is any chance the lengths differ, use itertools.zip_longest() and handle the fill value explicitly. Or assert lengths match at the top of the function before zipping: assert len(a) == len(b), f'Expected equal lengths, got {len(a)} and {len(b)}'.len()) when processing parallel lists.zip_longest() when lengths may differ.range().zip() — clean, lazy, and self-documenting. Add an assert if you want to be safe.zip_longest() from itertools with an explicit fillvalue — never silently lose data from a longer sequencefor name, score, year in zip(a, b, c) works exactly as you'd expectControlling the Loop — break, continue, and the else Clause
Sometimes you don't want to visit every single item. Maybe you're searching a list for a specific value and want to stop the moment you find it — no point checking the remaining 9,000 entries. That's break. It exits the loop immediately, as if you slammed a book shut mid-page.
continue is different — it doesn't stop the loop, it just skips the rest of the current iteration and jumps straight to the next one. Useful when you want to process most items but gracefully skip ones that fail a validation check without adding a layer of nesting.
Python's for loop also has an else clause, and this genuinely surprises most people the first time they encounter it — including some fairly experienced developers. The else block runs only if the loop completed normally, meaning it finished all iterations WITHOUT being interrupted by a break. Think of it as a 'loop completion handler' rather than an alternative condition. It's perfect for search-and-report patterns: loop through items looking for something, break if found, and use else to handle the 'not found' case cleanly without a flag variable.
One important detail that's easy to miss: break and continue only affect the innermost loop they live inside. In a nested loop, break exits the inner loop and returns control to the outer loop — it does not exit both. If you need to exit two levels of nesting at once, the cleanest approach by far is to move the nested logic into a separate function and use return. That eliminates the flag variable pattern, makes the search logic independently testable, and keeps the outer loop readable.
for loops can have an else. Interviewers love asking about this because it signals you've gone beyond surface-level syntax knowledge. The key point: else runs when the loop finishes without a break. Think of it as 'completed without interruption.' A correct answer that also mentions the nested loop exit pattern — where break only exits the innermost loop, and the clean fix is a function with return — will consistently stand out in a technical interview.List Comprehensions — When a for Loop Fits on One Line
A list comprehension is a compact way to create a new list by transforming or filtering items from an existing collection. It isn't a separate concept from for loops — it IS a for loop, written in a single expression. Python evaluates it by running the loop internally and collecting the results into a new list.
The anatomy: [expression for item in iterable]. With filtering: [expression for item in iterable if condition]. The result is always a new list. The original iterable is untouched.
This matters because the most common for loop pattern in Python is the accumulator: create an empty list, loop, conditionally append. That's three moving parts, each of which can be written slightly wrong. The list comprehension collapses all three into one expression that's harder to get wrong and easier to read at a glance — once you're familiar with the syntax.
There's a readability threshold to keep in mind, though. A comprehension with a simple filter and transform is genuinely cleaner than the accumulator loop. A comprehension with complex nested logic or a multi-step transformation is not — at that point, write the loop explicitly. The goal is readable code, not compact code for its own sake.
Beyond lists, Python has dict comprehensions ({k: v for k, v in items}), set comprehensions ({x for x in items}), and generator expressions ((x for x in items)). Generator expressions deserve special attention: they produce one item at a time rather than building the entire result in memory. If you're passing the result directly to , sum(), max(), or any(), use a generator expression instead of a list comprehension — there's no reason to materialise the entire list just to hand it off and immediately discard it.all()
- List comprehension: [expr for item in iterable] — builds the full list immediately, supports indexing and
len(), reusable - Generator expression: (expr for item in iterable) — lazy, one item at a time, single-use, cannot index into it
- When passing to
sum(), the outer parentheses already exist: sum(x**2 for x in range(n)) — no extra brackets needed - If the comprehension body needs multiple lines or complex logic, use a regular for loop — readability wins over compactness
- Nested comprehensions (list of lists) work but become hard to read quickly — use named for loops when nesting exceeds one level
sum([x for x in large_list if condition]) — building a full list in memory just to sum it and throw it away on the next line.sum(x for x in large_list if condition) is a generator expression — constant memory, identical result.sum(), any(), all(), max(), or min(), drop the square brackets. The outer function call already provides the parentheses you need.sum(), any(), all(), max().sum(), max(), any(), all()Nested Loops and When to Avoid Them
A nested loop is a loop inside another loop. The outer loop runs once; for each of its iterations, the inner loop runs to completion. If the outer loop has 5 items and the inner loop has 5 items, you get 25 total iterations. If both loops have 1,000 items, you get 1,000,000. This O(n²) growth is the thing to watch — nested loops that perform fine on small development datasets can become genuinely painful on production data at scale.
The canonical correct use for a nested loop is two-dimensional data: a grid, a matrix, a table where you need to visit every cell. Outer loop for rows, inner loop for columns. That's the right tool for that structure.
The anti-pattern is using a nested loop when Python's standard library already has something that does the job more efficiently. The most common case: checking whether any item from list A exists in list B. With nested loops, every item in A is checked against every item in B — O(n²). Converting list B to a set first reduces each membership check to O(1), making the overall algorithm O(n). That difference is irrelevant at 50 items and enormous at 50,000.
For generating every combination of two sequences — every pair, every permutation — is the clean alternative to nested loops. Same output, less code, and the intent ('I want the cartesian product') is explicit in the function name rather than implied by the loop structure.itertools.product()
- Nested loops on 100-item lists = 10,000 operations. On 10,000-item lists = 100,000,000 operations.
- If the inner loop body is just an 'in' check, convert the outer collection to a set before the loop starts
- itertools.product() generates cartesian products with cleaner syntax and explicit intent — prefer it over nested for loops for combination generation
- Deeply nested loops — three levels or more — are almost always a signal the code needs to be broken into named functions
- Profile before optimising — sometimes the O(n²) loop is completely acceptable because n is always small in practice
set_b = set(list_b) before the outer loop — is one of the highest-impact single-line performance fixes you can make in Python.itertools.product() for cartesian products instead of nested loops — same result, explicit intent.enumerate() at both levels for clean index trackingSilent Data Loss from Modifying a List During Iteration
for item in records: and called records.remove(item) inside the loop body when a condition matched. Removing an item shifts all subsequent indices left by one, but Python's internal iterator keeps advancing its position counter regardless. So the item that slides into the just-vacated slot gets skipped entirely on the next step. Every deletion caused one skip. With roughly half the records matching the delete condition, approximately half ended up being skipped — which explains the 47% figure almost exactly.for item in records[:]: to iterate over a shallow copy while modifying the original list. The better long-term fix: replaced the entire pattern with a list comprehension — records = [r for r in records if not should_delete(r)] — which is both safer and expresses intent more clearly. No mutation, no iterator gymnastics, and the code reads as a declarative statement of what you want to keep rather than a procedural description of what to remove.- Never modify a list while iterating over it directly — use a copy or build a new list
- Silent bugs are worse than crashes — always verify loop results with assertions or counts after the fact
- List comprehensions are both safer and more Pythonic than in-place modification loops
- Add logging that reports expected vs. actual item counts after any cleanup operation — 'success' should mean something verifiable
for item in list[:] or a list comprehension insteaditertools.zip_longest() if you need to process all items from both sequencesfor item in list[:] or a list comprehension to build a new list — never mutate what you're iterating overKey takeaways
range() is lazy — 48 bytes regardless of size, and it supports indexing and membership checks without being converted to a list.enumerate() instead of a manual counter variable or range(len())zip() to iterate over two sequences in locksteplen()) on parallel lists. zip() stops at the shorter sequence silently; use itertools.zip_longest() when lengths may differ and silent data loss would be a problem.sum(), any(), all(), max(), drop the square brackets and let Python stay lazy.Common mistakes to avoid
6 patternsModifying a list while looping over it directly
for item in my_list[:] or build a filtered version with a list comprehension: cleaned = [item for item in my_list if not should_remove(item)]. The list comprehension is the preferred approach — it's safer, reads as a declarative statement of what you want to keep, and doesn't mutate anything.Expecting range(n) to include n in its output
range(1, n+1) to get 1 through n inclusive. When in doubt, print list(range(...)) to verify before using it in real logic.Using the loop variable after the loop and trusting its value
Forgetting that for...else runs when the loop does NOT break
Using range(len(my_list)) instead of enumerate() for indexed iteration
len(), and introduces manual index arithmetic that creates off-by-one opportunities on every use.for i, item in enumerate(my_list) — it works on any iterable, handles the index automatically, and reads as natural English. The range(len()) pattern is the C or Java index loop ported into Python where it doesn't belong.Using zip() when the two sequences might have different lengths
itertools.zip_longest(a, b, fillvalue=None) when lengths might differ. Add an assertion before zipping when they must be equal: assert len(a) == len(b), f'Length mismatch: {len(a)} vs {len(b)}' — fail loudly rather than silently losing data.Interview Questions on This Topic
What is the difference between break and continue in a Python for loop, and can you give a practical example of when you'd use each?
break exits the loop entirely — no more iterations run after it fires. Use it when you've found what you were looking for and there's no point continuing. continue skips only the rest of the current iteration and moves immediately to the next one — the loop keeps going. Use it when you want to process most items but skip ones that fail a condition without adding a nesting level. Example: break when you find a target username in a list; continue to skip negative scores when computing an average of valid entries. One important detail: both break and continue only affect the innermost loop they live inside — in nested loops, break exits the inner loop and returns control to the outer one, not both.Frequently Asked Questions
That's Control Flow. Mark it forged?
10 min read · try the examples if you haven't