Pythonlistsloopsiteration

Python: удаление из списка в цикле пропускает элементы

Цикл for идёт по списку внутренним индексом, а remove() и del сдвигают хвост влево — поэтому после каждого удаления один элемент проскакивает мимо проверки. Исключения нет, список молча возвращается наполовину отфильтрованным. Разбираем механику по шагам и три способа отфильтровать список правильно.

10 мин чтенияСправочникPython · lists · loops · iteration · bugs

Короткий ответ: список не «игнорирует» твоё условие. Цикл for идёт по списку внутренним целочисленным индексом, а remove() и del сдвигают все элементы правее удалённого на одну позицию влево — на следующем шаге индекс вырастает на единицу, список при этом стал на единицу короче, и ровно один элемент проскакивает мимо проверки.

Исключения при этом не будет. Python молча вернёт наполовину отфильтрованный список, и заметишь ты это уже в отчёте или в базе. Ниже — пошаговая трассировка индекса и три способа, которые действительно работают, с выводом настоящего интерпретатора.

Почему при удалении элемента из списка в цикле пропускается следующий?

Потому что итератор списка хранит всего одно число — позицию, на которой он сейчас стоит. Про удаления он ничего не знает. Когда ты вызываешь remove() или del, элементы правее вырезанного сдвигаются влево и занимают освободившиеся позиции. Итератор на следующем шаге просто прибавляет к своей позиции единицу — и получает уже не тот элемент, который лежал бы там до удаления. Тот сдвинулся на шаг назад и остался непроверенным.

Вот как это выглядит на живом коде:

statuses = ["новый", "просрочен", "просрочен", "оплачен", "просрочен"]
for s in statuses:
    if s == "просрочен":
        statuses.remove(s)
print(statuses)
['новый', 'оплачен', 'просрочен']

Один «просрочен» выжил. Условие написано верно, remove() отработал верно, а результат неправильный.

Механика: почему в Python удаление элементов из списка в цикле пропускает элементы

for s in statuses — это не «перебор элементов», как кажется по синтаксису. Python вызывает iter(statuses), получает объект-итератор и на каждом шаге дёргает у него __next__(). Итератор списка устроен предельно просто: он держит ссылку на сам список и целое число — текущий индекс. Пока индекс меньше текущей длины списка, итератор возвращает lst[index] и увеличивает индекс на единицу. Как только индекс дорос до длины — цикл заканчивается.

Отсюда два следствия, из которых и складывается баг:

  1. Длина сверяется на каждом шаге заново, поэтому исключения нет: список просто «кончается» раньше, чем ты рассчитывал.
  2. Индекс всегда растёт ровно на единицу, что бы ты со списком ни сделал. Удалил элемент — всё, что было правее, сдвинулось влево, и следующий по очереди элемент оказался на уже пройденной позиции.

Чем remove(), pop() и del отличаются друг от друга, разобрано в справочнике: удаление элементов списка.

Трассировка по шагам: индекс растёт, список сжимается

Тот же цикл for, выписанный вручную через индекс, — это ровно то, что делает интерпретатор внутри:

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]

Смотри на строку index=2: индекс сдвинулся с 1 на 2, но вторая двойка успела переехать на позицию 1 — цикл её уже никогда не увидит.

Шаг index Элемент Что произошло Длина после шага
1 0 1 нечётное, оставили 8
2 1 2 удалили, хвост сдвинулся влево 7
3 2 3 вторая 2 уехала на позицию 1 и пропущена 7
4 3 4 удалили 6
5 4 4 удалили 5
5 5 не меньше 5, цикл закончился 5

Итог: пять шагов вместо восьми, в списке остались чётные 2 и 4, а хвостовая 5 вообще ни разу не проверялась — цикл закончился раньше, чем до неё дошла очередь.

Почему подряд идущие дубликаты выживают через один?

Это самая узнаваемая подпись бага. Каждое удаление съедает ровно одну позицию, поэтому из цепочки одинаковых значений цикл проверяет каждое второе: первый дубль удаляется, второй проскакивает, третий удаляется, четвёртый проскакивает. Если в списке подряд шли три "просрочен", после цикла останется один. Именно из-за этого кажется, будто Python «через раз игнорирует» условие.

Есть и вторая ловушка. remove() удаляет первое совпадение по значению, а вовсе не тот элемент, на котором сейчас стоит цикл:

labels = ["скидка", "новинка", "скидка"]
labels.remove("скидка")
print(labels)
['новинка', 'скидка']

То есть при дубликатах ты даже не контролируешь, какую именно позицию вырезал. Так что жалоба «python remove в цикле for работает неправильно» означает на самом деле другое: remove() работает ровно как задокументировано, неправильна сама связка «итератор плюс изменение списка под ним».

Способ 1: итерация по копии списка

Самая маленькая правка: перебирай копию, а изменяй оригинал. Срез [:] создаёт новый список с теми же элементами, и его длина уже не меняется по ходу цикла.

statuses = ["новый", "просрочен", "просрочен", "оплачен", "просрочен"]
for s in statuses[:]:
    if s == "просрочен":
        statuses.remove(s)
print(statuses)
['новый', 'оплачен']

Важно: backup = statuses копией не является. Это второе имя того же самого объекта, и никакой защиты оно не даёт:

tasks = ["готово", "готово", "в работе", "готово"]
backup = tasks
for t in backup:
    if t == "готово":
        tasks.remove(t)
print(tasks)
['в работе', 'готово']

Копия — это statuses[:], list(statuses) или statuses.copy(). Итерация по копии списка удобна, когда тело цикла делает что-то ещё кроме удаления: пишет лог, отправляет письмо, считает статистику.

Способ 2: списковое включение вместо удаления

Самый питоничный вариант: не удалять лишнее, а собирать нужное. Один проход, никаких сдвигов, условие читается сразу.

statuses = ["новый", "просрочен", "просрочен", "оплачен", "просрочен"]
statuses = [s for s in statuses if s != "просрочен"]
print(statuses)
['новый', 'оплачен']

Тут есть тонкость, на которой спотыкаются регулярно: строка выше создаёт новый список и переназначает имя. Если на старый список ссылается кто-то ещё, он ничего не заметит:

orders = ["новый", "просрочен", "оплачен"]
archive = orders
orders = [s for s in orders if s != "просрочен"]
print(orders)
print(archive)
['новый', 'оплачен']
['новый', 'просрочен', 'оплачен']

Если нужно изменить именно тот же объект, присваивай в срез:

orders = ["новый", "просрочен", "оплачен"]
archive = orders
orders[:] = [s for s in orders if s != "просрочен"]
print(orders)
print(archive)
['новый', 'оплачен']
['новый', 'оплачен']

Разница между «переназначить имя» и «изменить объект» — отдельная большая тема, и она же стоит за соседним багом: цикл for не меняет список. Синтаксис включений собран в справочнике.

Способ 3: обход в обратном порядке и del по индексу

Если удалять с конца, сдвиг задевает только уже пройденные позиции — и ломать становится нечего.

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]

Вариант с while и ручным индексом делает то же самое, но нагляднее: индекс увеличивается только тогда, когда элемент оставили.

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]

Оба варианта нужны, когда решение об удалении зависит от индекса или когда список огромный и заводить копию не хочется.

Почему enumerate() и range(len()) не спасают?

Не спасают, потому что проблема не в том, что тебе не хватало индекса. enumerate() оборачивает тот же самый итератор списка, поэтому счётчик и реальная позиция расходятся точно так же — просто теперь ты видишь номер и веришь, что всё под контролем. А range(len(lst)) вычисляет длину ровно один раз, до начала цикла, поэтому после удалений индекс уезжает за конец списка, и тихий баг превращается в падение.

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

А вот что делает 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 — соседняя ошибка того же семейства «список проиндексировали не тем»; ей посвящён отдельный разбор.

Как удалить элементы из списка по условию: что выбрать?

Если условие простое и порядок нужно сохранить — бери списковое включение: оно короче, быстрее и вообще не трогает список во время обхода. Итерация по копии нужна, когда цикл делает что-то помимо фильтрации. Обратный обход с del — когда важен индекс или жалко памяти. И отдельный случай — дубликаты: там ни один из трёх способов не подходит, нужен набор уже встреченных значений.

Способ Код Когда брать Подводный камень
Копия for x in lst[:] в цикле есть побочные действия лишняя копия в памяти, remove() бьёт по первому совпадению
Включение lst = [x for x in lst if ...] обычная фильтрация переназначает имя, а не меняет объект
Присваивание в срез lst[:] = [x for x in lst if ...] нужен тот же объект легко спутать с обычным присваиванием
Обратный обход for i in range(len(lst) - 1, -1, -1) нужен индекс, жалко памяти читается тяжелее всех
filter() list(filter(pred, lst)) предикат уже есть отдельной функцией нужен list(), лямбда читается хуже включения

Отдельно про то, как удалить дубликаты из списка: в цикле с remove() результат зависит от порядка и почти всегда неверен. Надёжный приём — множество уже увиденных значений, оно же сохраняет порядок первых вхождений:

names = ["Аня", "Борис", "Аня", "Вера", "Борис", "Аня"]
seen = set()
unique = []
for name in names:
    if name not in seen:
        seen.add(name)
        unique.append(name)
print(unique)
['Аня', 'Борис', 'Вера']

set(names) короче, но теряет порядок, а на списке списков ещё и падает с TypeError: unhashable type: 'list' — почему так, разобрано в статье про unhashable type: 'list'.

Закрепить приём «собрать новый список вместо удаления из старого» можно прямо в браузере на задаче Только неубывающие, а дедупликацию с сохранением порядка — на задаче Оставь только первые символы.

Что дешевле на большом списке: remove() в цикле или включение?

Включение дешевле, и дело не в микрооптимизации, а в асимптотике. Каждый remove() и каждый del lst[i] физически сдвигает все элементы правее удалённого на одну позицию — это работа, пропорциональная длине хвоста. Удаляешь много элементов в цикле — получаешь квадратичное время. Включение же проходит список один раз и просто не копирует то, что не подошло. Плата за это — второй список в памяти на время сборки.

Обратный обход с del дополнительной памяти не требует, но по времени остаётся квадратичным. Практическое правило простое: на нескольких тысячах элементов разница незаметна, дальше — включение.

Почему у словаря та же ошибка падает с исключением?

Потому что словарь умеет заметить подмену, а список — нет. Итератор словаря запоминает его размер в момент создания и сверяет на каждом шаге; если размер изменился, он поднимает RuntimeError. Список так не делает: его итератор хранит только индекс, а любой индекс меньше текущей длины формально валиден. В итоге словарь падает громко и сразу, а список молча возвращает неправильные данные — и именно поэтому список опаснее.

stock = {"хлеб": 3, "молоко": 0, "сыр": 7, "кефир": 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

Запрос «dictionary changed size during iteration python» лечится тем же приёмом — обходом копии ключей:

stock = {"хлеб": 3, "молоко": 0, "сыр": 7, "кефир": 0}
for name in list(stock):
    if stock[name] == 0:
        del stock[name]
print(stock)
{'хлеб': 3, 'сыр': 7}

Или словарным включением — stock = {name: qty for name, qty in stock.items() if qty > 0} даёт тот же результат. У множеств поведение такое же, только текст другой: RuntimeError: Set changed size during iteration. Отработать фильтрацию словаря по условию можно на задаче Оставить значения от порога.

Частые ошибки

  1. for x in lst: lst.remove(x). Классика жанра: уходит каждый второй подходящий элемент, исключения нет. Фикс — for x in lst[:] или включение.
  2. Вера в enumerate(). Запись for i, x in enumerate(lst): lst.pop(i) защиты не даёт: внутри тот же итератор списка и тот же сдвиг. Фикс тот же — не мутируй список, по которому идёшь.
  3. for i in range(len(lst)) с удалением. Длина посчитана один раз, поэтому после нескольких удалений прилетит IndexError: list index out of range. Фикс — идти с конца: range(len(lst) - 1, -1, -1).
  4. backup = lst вместо копии. Два имени, один объект — цикл всё равно идёт по изменяемому списку. Фикс — lst[:], list(lst) или lst.copy().
  5. Переприсваивание внутри функции. Строка values = [v for v in values if v != 0] внутри функции меняет только локальное имя, а вызывающий код увидит старый список. Фикс — values[:] = [v for v in values if v != 0].
  6. Удаление дубликатов через lst.count(x) > 1 в цикле. Каждый remove() бьёт по первому совпадению, поэтому результат зависит от порядка и почти всегда неверен. Фикс — набор seen.
  7. Удаление ключей словаря прямо в for k in d. Здесь Python сразу поднимет RuntimeError. Фикс — for k in list(d) или словарное включение.

Практика: отфильтруй список тремя способами

Прогони этот код целиком и убедись, что все три способа дают один и тот же результат:

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

А теперь убери [:] из строки for x in by_copy[:] и запусти ещё раз. Первая строка вывода станет [12, 7, 0, 3], а последняя — False: один ноль пережил фильтрацию. Это и есть весь баг в разнице длиной в два символа.

Больше задач на списки с проверкой прямо в браузере — в каталоге: бесплатные задачи по спискам.

Мини-резюме

  • Цикл for идёт по внутреннему индексу, а remove() и del сдвигают хвост влево — поэтому в Python удаление элементов из списка в цикле пропускает элементы.
  • Исключения не будет: список молча вернётся наполовину отфильтрованным, и ошибка всплывёт позже.
  • Подряд идущие дубликаты выживают через один — это опознавательный знак именно этого бага.
  • Три рабочих способа: копия lst[:], включение [x for x in lst if ...], обход с конца с del.
  • lst = [...] переназначает имя, lst[:] = [...] меняет тот же объект — разница критична для функций и общих ссылок.
  • enumerate() и range(len()) не спасают: первый прячет баг, второй превращает его в IndexError.
  • У словаря и множества та же ошибка падает громко: RuntimeError: dictionary changed size during iteration.

Закрепи на практике

Решай задачи в Python-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр