Pythonlistsloopsiteration

Deleting From a List While Looping Skips Items in Python

A for loop walks a list by an internal index while remove() and del shift the tail left, so one element slips past the check after every deletion. Nothing is raised and the list comes back half-filtered. Here is the mechanism step by step plus three fixes that work.

9 min readReferencePython · lists · loops · iteration · bugs

Short answer: the list is not ignoring your condition. A for loop walks the list by an internal integer index, and remove() or del shifts everything to the right of the deleted element left by one position — the index then grows by one while the list got one shorter, so exactly one element slips past your check.

No exception is raised: Python quietly hands you a half-filtered list. Below is a step-by-step trace of the index and three fixes that work.

Why does removing an item from a list inside a loop skip the next one?

Because the list iterator stores exactly one thing: the position it is on right now. It knows nothing about deletions. When you call remove() or del, the elements right of the removed one shift left into the freed positions. The iterator then adds one to its position and gets an element that is no longer the one that would have been there — the one that should have been checked moved back a step and is never visited.

In running code:

statuses = ["new", "overdue", "overdue", "paid", "overdue"]
for s in statuses:
    if s == "overdue":
        statuses.remove(s)
print(statuses)
['new', 'paid', 'overdue']

One "overdue" survived: the condition is right, remove() is right, the result is wrong.

The mechanism: why deleting items from a list while looping skips elements

for s in statuses is not "iterate the elements", whatever the syntax suggests. Python calls iter(statuses) and then __next__() on the resulting iterator each step. That iterator holds the list plus one integer — the current index. While the index is below the list's current length it returns lst[index] and bumps the index; at the length, the loop ends.

Two consequences make the bug:

  1. The length is rechecked every step, so there is no exception — the list just runs out early.
  2. The index always grows by one, whatever you did. Delete an element, everything right of it shifts left, and the next element lands on a position the loop already passed.

How remove(), pop() and del differ is in the handbook: list element removal.

Step by step: what the index and the length actually do

The same for loop written by hand with an index is what the interpreter does internally:

numbers = [1, 2, 2, 3, 4, 4, 4, 5]
index = 0
while index < len(numbers):
    n = numbers[index]
    print(f"index={index}  n={n}  len={len(numbers)}  {numbers}")
    if n % 2 == 0:
        numbers.remove(n)
    index += 1
print(numbers)
index=0  n=1  len=8  [1, 2, 2, 3, 4, 4, 4, 5]
index=1  n=2  len=8  [1, 2, 2, 3, 4, 4, 4, 5]
index=2  n=3  len=7  [1, 2, 3, 4, 4, 4, 5]
index=3  n=4  len=7  [1, 2, 3, 4, 4, 4, 5]
index=4  n=4  len=6  [1, 2, 3, 4, 4, 5]
[1, 2, 3, 4, 5]

Look at index=2: the index moved from 1 to 2, but the second 2 had already slid to position 1 — the loop will never see it.

Step index Element What happened Length after
1 0 1 odd, kept 8
2 1 2 removed, tail shifted left 7
3 2 3 the second 2 slid to position 1, skipped 7
4 3 4 removed 6
5 4 4 removed 5
5 5 is not less than 5, loop ended 5

Five steps instead of eight, an even 2 and 4 left behind, the trailing 5 never inspected.

Why do consecutive duplicates survive every other one?

This is the bug's signature. Each deletion eats one position, so out of a run of identical values the loop checks every second one: the first duplicate goes, the second slips through, the third goes, the fourth slips through. Three "overdue" entries in a row leave one standing — which is why it feels like Python ignores the condition half the time.

A second trap: remove() deletes the first match by value, not the element the loop is standing on.

labels = ["sale", "new", "sale"]
labels.remove("sale")
print(labels)
['new', 'sale']

So with duplicates you do not even control which position you cut. "remove() in a for loop works incorrectly" means something else: remove() is exactly as documented; the broken part is pairing an iterator with a list mutated under it.

Fix 1: iterate over a copy of the list

Smallest possible edit: loop over a copy, mutate the original. The slice [:] builds a new list whose length never changes during the loop.

statuses = ["new", "overdue", "overdue", "paid", "overdue"]
for s in statuses[:]:
    if s == "overdue":
        statuses.remove(s)
print(statuses)
['new', 'paid']

Important: backup = statuses is not a copy — it is a second name for the same object and protects nothing:

tasks = ["done", "done", "in progress", "done"]
backup = tasks
for t in backup:
    if t == "done":
        tasks.remove(t)
print(tasks)
['in progress', 'done']

A copy is statuses[:], list(statuses) or statuses.copy(). Iterating a copy is right when the loop body does more than delete — logs, sends mail, updates a counter.

Fix 2: build a new list with a comprehension

The Pythonic option: stop deleting what you do not want, collect what you do. One pass, no shifting, condition visible at a glance.

statuses = ["new", "overdue", "overdue", "paid", "overdue"]
statuses = [s for s in statuses if s != "overdue"]
print(statuses)
['new', 'paid']

One subtlety trips people up: that line creates a new list and rebinds the name. Anything else holding the old list notices nothing:

orders = ["new", "overdue", "paid"]
archive = orders
orders = [s for s in orders if s != "overdue"]
print(orders)
print(archive)
['new', 'paid']
['new', 'overdue', 'paid']

To change that very object, assign into a slice:

orders = ["new", "overdue", "paid"]
archive = orders
orders[:] = [s for s in orders if s != "overdue"]
print(orders)
print(archive)
['new', 'paid']
['new', 'paid']

"Rebind a name" versus "mutate an object" is also behind the neighbouring bug: a for loop variable does not change the list. Comprehension syntax is in the handbook.

Fix 3: walk backwards and delete by index

Delete from the end and the shift only touches positions the loop already passed — nothing left to break.

prices = [120, 0, 340, 0, 0, 55]
for i in range(len(prices) - 1, -1, -1):
    if prices[i] == 0:
        del prices[i]
print(prices)
[120, 340, 55]

A while loop with a manual index is plainer: the index moves forward only when the element was kept.

prices = [120, 0, 340, 0, 0, 55]
i = 0
while i < len(prices):
    if prices[i] == 0:
        del prices[i]
    else:
        i += 1
print(prices)
[120, 340, 55]

Both fit when the index matters or a copy is too expensive.

Why don't enumerate() and range(len()) help?

They do not help because the problem was never a missing index. enumerate() wraps the very same list iterator, so counter and real position drift apart exactly as before — you just get a number and a false sense of control. And range(len(lst)) computes the length once, before the loop starts, so after a few deletions the index runs past the end and the silent bug becomes a crash.

scores = [10, 0, 0, 25]
for i, score in enumerate(scores):
    if score == 0:
        scores.pop(i)
print(scores)
[10, 0, 25]

Now range(len(...)):

prices = [120, 0, 340, 0, 0, 55]
for i in range(len(prices)):
    if prices[i] == 0:
        del prices[i]
print(prices)
Traceback (most recent call last):
  File "/home/user/prices.py", line 3, in <module>
    if prices[i] == 0:
       ~~~~~~^^^
IndexError: list index out of range

list indices must be integers is the neighbour in the same family — a list indexed by the wrong thing — with its own walkthrough.

Which way should you remove list items by condition?

Take the comprehension when the condition is simple and order matters: shorter, faster, and it never touches the list mid-walk. Iterating a copy is for loops that do more than filter. Backwards traversal with del is for when the index matters or memory is tight. Duplicates are a separate case — you need a set of values already seen there.

Approach Code When to use Gotcha
Copy for x in lst[:] loop has side effects extra memory; remove() hits the first match
Comprehension lst = [x for x in lst if ...] ordinary filtering rebinds, does not mutate
Slice assignment lst[:] = [x for x in lst if ...] same object must change looks like plain assignment
Backwards for i in range(len(lst) - 1, -1, -1) index matters, memory is tight hardest to read
filter() list(filter(pred, lst)) predicate is already a function lambda reads worse

About duplicates: remove() in a loop is order-dependent and almost always wrong. A seen set keeps first occurrences in order:

names = ["Anna", "Boris", "Anna", "Vera", "Boris", "Anna"]
seen = set()
unique = []
for name in names:
    if name not in seen:
        seen.add(name)
        unique.append(name)
print(unique)
['Anna', 'Boris', 'Vera']

set(names) is shorter but loses order, and on a list of lists it raises TypeError: unhashable type: 'list' — unpacked in unhashable type: 'list'.

Drill it in the browser: Non-decreasing only builds a filtered list, Keep only first occurrences deduplicates in order.

Is a comprehension cheaper than remove() on a large list?

Yes, and not as a micro-optimisation — the difference is asymptotic. Every remove() and every del lst[i] physically shifts all elements right of the removed one by one position, which is work proportional to the tail length. Many deletions in a loop give quadratic time. A comprehension walks the list once and skips what did not match; the price is a second list in memory while it is built.

Backwards traversal with del needs no extra memory but stays quadratic. Rule of thumb: invisible at a few thousand elements, use the comprehension beyond that.

Why does the same bug raise an exception for a dict?

Because a dict notices the swap and a list does not. A dict iterator records the size when it is created and rechecks it every step; if it changed, it raises RuntimeError. A list does no such thing: its iterator stores only an index, and any index below the current length is formally valid. So the dict fails loudly at once while the list quietly returns wrong data — which is why the list version is more dangerous.

stock = {"bread": 3, "milk": 0, "cheese": 7, "kefir": 0}
for name in stock:
    if stock[name] == 0:
        del stock[name]
print(stock)
Traceback (most recent call last):
  File "/home/user/shop.py", line 2, in <module>
    for name in stock:
                ^^^^^
RuntimeError: dictionary changed size during iteration

The cure is the same trick — walk a copy of the keys:

stock = {"bread": 3, "milk": 0, "cheese": 7, "kefir": 0}
for name in list(stock):
    if stock[name] == 0:
        del stock[name]
print(stock)
{'bread': 3, 'cheese': 7}

A dict comprehension works too: stock = {name: qty for name, qty in stock.items() if qty > 0}. Sets behave the same way, worded RuntimeError: Set changed size during iteration. Practise on Keep values above a threshold.

Common mistakes

  1. for x in lst: lst.remove(x). Every second matching element survives, silently. Fix — for x in lst[:] or a comprehension.
  2. Trusting enumerate(). for i, x in enumerate(lst): lst.pop(i) protects nothing: same iterator, same shift. Fix — do not mutate the list you walk.
  3. for i in range(len(lst)) with deletion. The length is computed once, so you get IndexError: list index out of range. Fix — range(len(lst) - 1, -1, -1).
  4. backup = lst instead of a copy. Two names, one object. Fix — lst[:] or list(lst).
  5. Rebinding inside a function. values = [...] changes only the local name. Fix — values[:] = [...].
  6. Deduplicating with lst.count(x) > 1. remove() hits the first match, so the result is order-dependent. Fix — a seen set.
  7. Deleting dict keys inside for k in d. Python raises RuntimeError at once. Fix — for k in list(d).

Practice: filter one list three ways

Run this — all three approaches agree:

raw = [0, 12, 0, 7, 0, 0, 3]

by_copy = raw[:]
for x in by_copy[:]:
    if x == 0:
        by_copy.remove(x)

by_comprehension = [x for x in raw if x != 0]

backwards = raw[:]
for i in range(len(backwards) - 1, -1, -1):
    if backwards[i] == 0:
        del backwards[i]

print(by_copy)
print(by_comprehension)
print(backwards)
print(by_copy == by_comprehension == backwards)
[12, 7, 3]
[12, 7, 3]
[12, 7, 3]
True

Now drop the [:] from for x in by_copy[:] and run it again: the first line becomes [12, 7, 0, 3] and the last becomes False. One zero survived — two characters of difference are the whole bug.

More list problems, checked in the browser: free list tasks.

In short

  • A for loop walks an internal index while remove() and del shift the tail left — that is why deleting items from a list while iterating skips elements.
  • Nothing is raised: the list comes back half-filtered and the damage shows up later.
  • Consecutive duplicates surviving every other one is the signature of this bug.
  • Three fixes: a copy lst[:], a comprehension, or a backwards walk with del.
  • lst = [...] rebinds the name, lst[:] = [...] mutates the same object.
  • enumerate() hides the bug and range(len()) turns it into IndexError.
  • A dict or a set fails loudly: RuntimeError: dictionary changed size during iteration.

Practice on real tasks

Solve tasks in the Python trainer with instant grading and hints.

Open trainer