Nothing here is broken either. It is the mechanism you need in order to see why the next tab loses rows — watch the index, not the values.
letters = ['a', 'b', 'c', 'd']
for letter in letters:
print(letter)
if letter == 'b':
letters.remove(letter)
# prints a, b, d — c is never visited
# After removing 'b', 'c' slides down into position 1,
# but the loop has already moved on to position 2.
- The loop remembers a position, not the items
A for loop over a list holds an index that starts at 0 and goes up by one each pass. It does not hold a copy of the items, and it does not recheck the length against what it has already seen. Remove something and everything after it shifts down one place — into a position the loop has already passed.
- Removing skips; inserting repeats
The mirror image is just as real: inserting during iteration can make the loop visit an item twice, or never finish at all. Both are the same underlying rule — the indices moved and the loop did not notice.
- It fails quietly, which is what makes it dangerous
No exception, no warning, just fewer rows removed than you asked for. Compare that with modifying a dict while iterating it, which raises RuntimeError immediately. Lists let you do it, so the mistake reaches production and shows up later as bad data.
Look at which cases pass and which fail. Single blanks are fine; adjacent blanks are not. What is different about the second blank in a pair, from the loop’s point of view?
The loop is walking the very list it is removing from. When an item goes, everything after it shifts down one position — and the loop’s next step moves forward one position, so exactly one row gets stepped over.
Stop removing. Build a new list of the rows you want to keep instead: decide per row whether it has content, and collect the ones that do. Nothing is modified while it is being read, so the whole class of bug disappears rather than being patched around.
def remove_blanks(rows): return [row for row in ____ if row.____()]
def remove_blanks(rows):
return [row for row in rows if row.strip()]
The bug is not really the remove() call — it is the decision to edit a list while reading it. Building a new list from the items you want reads the input once, writes to something else, and cannot suffer from shifted indices at all. Whenever you catch yourself deleting from the thing you are looping over, invert it: say what to keep.
It removes the whitespace and, because an empty string is falsy, the result works directly as the test — no comparison with '' needed. Note the returned rows are the originals, not the stripped versions: strip() returns a new string and never touches the one it was called on. If you wanted trimmed output you would have to say so.
You could iterate over a copy — for row in list(rows) — and remove from the original, or walk backwards by index so shifts happen behind you. Both work. Both also require the next reader to reconstruct why, and the copy version still mutates the caller’s list. The comprehension needs no explanation, which is the strongest argument a piece of code can make.
If the list is enormous and you genuinely cannot afford a second copy in memory, in-place filtering has a place — rows[:] = [r for r in rows if r.strip()] rewrites the contents without building a lasting second list, though it does mutate the caller’s list on purpose. And if the rows are streaming from a file, do not build a list at all: filter as you read, with a generator, and never hold the whole thing.
- Try the two 'clever' fixes — iterating over a copy, and walking backwards by index — and check both against every case here, including the one about the caller’s list.
- Do the same experiment with a dict instead of a list and note the difference: Python raises RuntimeError rather than quietly skipping. Ask yourself which behaviour you prefer.
- Turn it into a generator so a 2GB CSV never has to be in memory twice, then work out what that costs a caller who wants len() of the result.