Run it
The dict raising, the list silently skipping on the same logical operation, and the three fixes all producing the answer the broken loop was supposed to give.
The four correct approaches
Build a new collection — clearest, and the default choice:
Iterate over a copy when the original object must be mutated in place, because other references point at it:
list(d) is the idiom for dictionaries — it materialises the keys before the loop starts, so deletions during the loop cannot disturb it.
Slice-assign to mutate in place with no copy semantics visible to callers:
This is the underused one. It keeps the same list object — so aliases see the change — while doing the filtering safely.
Iterate backwards when removing by index, since removals then only affect positions already visited:
Two different failures
A dictionary keeps a version counter and compares it on each step, so a size change during iteration is caught immediately and loudly. That is a feature: the alternative is undefined behaviour, because a resize can move every entry to a different slot.
A list has no such check. Deleting element i shifts everything after it one place left, while the loop's internal index advances — so the element that moved into position i is never visited. You get a wrong answer and no error at all, which is the harder bug to find.
The fixes, in order of preference
Build a new collection. [x for x in items if keep(x)] or {k: v for k, v in d.items() if keep(k)}. Nothing is mutated, the intent is explicit, and it is usually faster than repeated deletion.
Iterate over a snapshot. for k in list(d): or for x in items[:]:. Necessary when you genuinely must mutate in place — because other references to the object are watching.
Filter in place backwards. Walking from the end means deletions only shift elements you have already passed. Correct, and worth knowing for when memory matters.
What counts as a change
For a dict, only a size change trips the check. Assigning to an existing key is fine, which surprises people who expect any mutation to raise. Adding a key raises just as deletion does.
Sets behave like dicts. collections.deque also raises. And modifying the object a variable points to — appending to a list stored as a dict value — is not a change to the dict, so it is perfectly legal.
Which to choose
| Situation | Approach |
|---|
| Filtering, no aliasing concerns | Comprehension — nums = [...] |
| Must keep the same object | Slice assignment — nums[:] = [...] |
| Removing by index | Iterate backwards |
| Dictionary keys | for k in list(d) |
| Very large collection, memory-bound | In-place two-pointer compaction |
| Adding while iterating | Collect additions, extend after the loop |
The aliasing distinction is the one that causes real bugs:
If a function is documented as modifying its argument, the comprehension form silently does nothing from the caller's perspective. Slice assignment is what actually mutates.
For the memory-bound case, two-pointer compaction filters in place with no second list:
O(n) time, O(1) extra space — the same pattern as "remove duplicates in place".
Adding during iteration
Growing a list while iterating it does not raise either — and it can loop forever:
The index keeps finding new elements. The fix is to accumulate separately:
The same discipline applies to a worklist algorithm, where the natural structure is an explicit queue rather than a for loop:
That is the correct shape for BFS, dependency resolution and crawling — anything where processing an item produces more items.
Questions people ask
Why does a list not raise like a dict? Lists have no version counter; iteration is a plain index. Dicts and sets track modifications because rehashing could otherwise corrupt the traversal.
Is nums[:] a deep copy? No, shallow — the new list holds the same element references. Fine for filtering, not for mutating the elements themselves.
Can I change dict values while iterating? Yes. Only size changes raise.
What about for k, v in d.items() and then del? Same error. Wrap in list(d.items()).
Is iterating backwards a hack? No — it is correct and O(n), and it is the right choice when removing by index in place.
What is fastest? A comprehension, usually — one pass, one allocation, and the loop runs in C.
Does this apply to generators? A generator over a mutating source gives undefined results; materialise it first if the source will change.
Recap in one screen
- Removing from a list while iterating silently skips elements, because the index advances past the shifted-down item.
- Dicts and sets raise
RuntimeError on a size change — a loud failure, which is better. - Prefer a comprehension; use
nums[:] = [...] when the caller's object must be mutated. - For dictionaries, iterate
list(d) to snapshot the keys first. - Growing a list while iterating it can loop forever — collect additions and
extend afterwards, or use an explicit worklist.