backend / python core / tricky questions / 38_dict_mutation_during_iteration.md

Mutating a dict during iteration

3 min read source

Mutating a dict during iteration

The gotcha

Adding or removing keys while iterating raises RuntimeError: dictionary changed size during iteration. Reassigning values is fine. The distinction trips people up — and so does the workaround for delete-while-iterating.

Minimal repro

d = {1: 'a', 2: 'b', 3: 'c'}

for k in d:
    if k == 2:
        del d[k]            # RuntimeError: dictionary changed size during iteration
# Re-assigning values is allowed:
for k in d:
    d[k] = d[k].upper()      # OK — size unchanged
# Adding keys also raises:
for k in list(d):
    d[k * 10] = "new"       # OK because we iterate over a list snapshot

Why it happens

Dict iteration relies on a stable internal layout. The dict tracks a version counter that increments on size change. The iterator captures this counter at start; on each __next__, it compares — mismatch raises.

Value reassignment doesn’t change size, so the version counter doesn’t tick. Iteration continues safely.

set and list have the same rule (with different error messages):

s = {1, 2, 3}
for x in s:
    s.add(4)            # RuntimeError: Set changed size during iteration

l = [1, 2, 3]
for x in l:
    l.append(4)         # silent infinite loop
                        # — list iterator advances by index, the loop never ends

(List iteration doesn’t raise — it just keeps going as the list grows. Different bug shape, same root cause.)

How to avoid

Iterate over a snapshot

for k in list(d):
    if k == 2:
        del d[k]                    # OK — iterating over a copy of keys

list(d) creates a one-time list of keys at iteration start. Mutating d during the loop doesn’t affect the list.

d.copy() does the same thing for items.

Build a new dict

Often cleaner than mutating in place:

d = {k: v for k, v in d.items() if k != 2}

Two-pass: collect, then delete

to_remove = [k for k, v in d.items() if some_condition(v)]
for k in to_remove:
    del d[k]

Best when the predicate is non-trivial — separates iteration logic from mutation.

What about popitem?

while d:
    k, v = d.popitem()              # OK — not iterating
    process(k, v)

Loop based on while d: rather than for k in d: — no iterator to invalidate.

What about dict.items() and views?

items = d.items()
print(items)                # dict_items([(1, 'a'), (2, 'b'), (3, 'c')])

del d[1]
print(items)                # dict_items([(2, 'b'), (3, 'c')])   ← view is dynamic

d.keys(), d.values(), d.items() return views, not snapshots. They reflect later changes. So:

for k in d.keys():
    if cond:
        del d[k]            # RuntimeError, same reason

Use list(d.keys()) for a snapshot.

Concurrent modification across threads

The RuntimeError is single-thread defensive. With multiple threads modifying the same dict, you can get partial reads, dropped entries, or KeyError during lookup — none of it deterministic. Dict is not thread-safe; use threading.Lock or dict reads in one thread + writes in another via a queue.

Interview angle

  • Q: “What happens if you delete a key while iterating?” — RuntimeError: dictionary changed size during iteration.
  • Q: “What about reassigning values?” — fine; size unchanged.
  • Follow-up: “How do you safely delete entries matching a predicate?” — iterate over list(d), or build a new dict via comprehension.
  • Follow-up: “Does the same apply to lists?” — list mutation during iteration doesn’t raise; it silently misbehaves (infinite loop on append, skipped elements on delete).

See collection_complexity_bigO, 02_python_core/32_dict_internals.md.