PYTHON / LOOPS
Iterating dictionaries safely
Add or remove dictionary entries around a loop without triggering RuntimeError, by iterating a snapshot or building a new dict.
What you will learn
- Recognise that keys(), values() and items() are live views, not copies
- Use list(d) or list(d.items()) to iterate a snapshot before mutating
- Rewrite delete-while-looping code as a two-pass or a dict comprehension
- Tell apart safe value reassignment from unsafe size changes
Understanding Iterating dictionaries safely
A dictionary iterator does not hold a private copy of the keys. It holds a reference to the dict plus a position, and it records how many entries the dict had when iteration started. Every time the loop asks for the next key, the iterator compares that recorded count with the dict's current count, and if they differ it raises RuntimeError: dictionary changed size during iteration. This check exists because inserting or deleting can force the dict to reorganise its internal table, at which point a saved position no longer points where it used to.
That is why the error message says changed size rather than changed. Assigning to a key that already exists only overwrites a value slot, so the entry count is unchanged and the loop continues normally. Adding a new key or deleting an existing one changes the count, and the very next step of the loop fails. The failure is also delayed rather than immediate: the deletion itself succeeds, so a loop that dies halfway through leaves the dictionary partly modified.
The fix is to separate the thing you are reading from the thing you are changing. Wrapping the dict in list() materialises the keys into a real list, and that list is unaffected by later insertions or deletions, so the loop runs to completion. The alternative is to not mutate at all: collect the keys you want to drop in a first pass and delete them in a second, or build a new dictionary with a comprehension and rebind the name. Comprehensions are usually clearest when you are filtering or transforming every entry, and the two-pass approach when only a few entries are affected.
scores = {"ana": 91, "bo": 48, "cy": 73, "di": 39}
# Deleting while looping over the dict itself breaks the iterator.
try:
for name in scores:
if scores[name] < 50:
del scores[name]
except RuntimeError as err:
print("RuntimeError:", err)
# The first deletion already happened before the error.
print(scores)
# list(scores) copies the keys, so the loop is immune to the deletions.
for name in list(scores):
if scores[name] < 50:
del scores[name]
print(scores)A dict iterator is invalidated by any change to the number of entries, so mutate a snapshot or produce a new dict instead of editing the one you are looping over.
Worked examples
Overwriting values is safe, resizing is not
Shows that reassigning existing keys inside items() works, and that a view reflects later insertions.
inventory = {"nails": 120, "screws": 8, "bolts": 45}
for item, count in inventory.items():
inventory[item] = count * 2
print(inventory)
view = inventory.keys()
print(view)
inventory["nuts"] = 3
print(view)Example explained
Line 1inventory[item] = count * 2 targets a key that already exists, so the entry count never changes and the iterator stays valid.
Line 2The loop reads count from the tuple yielded by items(), which is the value at the moment that entry was reached.
Line 3view is stored before the insertion, yet printing it afterwards shows 'nuts', proving keys() is a window onto the dict rather than a snapshot.
Line 4Had the loop body inserted a new key instead of overwriting one, the next iteration would have raised RuntimeError.
Two passes versus a comprehension
Two mutation-free patterns: collect keys first and delete after, or build a replacement dict.
stock = {"a": 0, "b": 5, "c": 0, "d": 2}
empty = [key for key, count in stock.items() if count == 0]
for key in empty:
del stock[key]
print(stock)
restocked = {key: count + 10 for key, count in stock.items()}
print(restocked)
print(stock)Example explained
Line 1The list comprehension finishes iterating before any deletion happens, so empty is a plain list of keys with no link to the dict.
Line 2The deletion loop iterates empty, not stock, so changing stock's size is harmless.
Line 3The dict comprehension reads stock and writes into a brand new object, so there is no iterator to invalidate.
Line 4Printing stock last shows the comprehension did not modify the original; you must rebind stock = restocked if you want the change to stick.
Popping items until the dict is empty
Draining a dictionary with a while loop, which avoids iterators entirely.
tasks = {"build": 3, "test": 1, "ship": 7}
while tasks:
name, cost = tasks.popitem()
print(f"{name} costs {cost}, {len(tasks)} left")
print(tasks)Example explained
Line 1while tasks: re-tests truthiness each round, so no iterator is created and no size check applies.
Line 2popitem() removes and returns the last inserted entry, which is why the order is the reverse of insertion.
Line 3len(tasks) drops on every pass, showing the dict really is shrinking under the loop.
Line 4popitem() raises KeyError on an empty dict, so the while condition is what keeps this correct.
Important notes
The size check runs before the iterator reports exhaustion, so removing the very last entry yielded still raises RuntimeError on the following step.
Sets follow the same rule with the message 'Set changed size during iteration', and after deleting a key, touching d[key] again raises KeyError rather than RuntimeError.
Common mistakes
Writing for key in d.keys(): del d[key] and expecting keys() to be a copy; it is a view onto the same dict, so the loop raises RuntimeError just as looping over d directly would.
Taking snapshot = list(d.items()) and then reading values from those tuples after modifying d; the tuples hold the old values, so later decisions are made on stale data.
Assuming the RuntimeError rolls the dictionary back; the deletions performed before the error remain, leaving a half-filtered dict that quietly produces wrong results if the exception is caught and ignored.
Try it yourself
Change, predict, then run
Start from counts = {"the": 9, "cat": 2, "sat": 1, "mat": 4} and remove every entry whose value is below 3, first with for word in counts: to see the RuntimeError, then fix it so the final dict prints as {'the': 9, 'mat': 4}.
Open the Python workspaceCheck your understanding
While looping with for key, value in d.items():, which body can run to completion without RuntimeError?
- d[key] = value * 2, where key already exists in d
- d.pop(key), removing the entry just yielded
- d.setdefault(key + "_copy", 0), which inserts a new key
- del d[key] when key is the final entry the loop would yield
Show answer
Overwriting an existing key leaves the entry count unchanged, and the iterator only compares counts, so it stays valid. Option 4 is tempting because the loop looks finished, but the iterator checks the size before it checks whether it has run out of entries, so it raises on the next step instead of stopping cleanly.