skip to content

Why does inserting a key into a dict during iteration raise RuntimeError?

level: middleimportance: must knowfreq 64%

answer

  1. Hash tables move entries when they grow
  2. The iterator remembers something about the container
  3. It re-checks on every step
  4. Only size changes are watched
  5. Snapshot the keys with list(d)

basics

~20 s

A dict iterator records the mapping's size when created and re-checks it on every step. Inserting or deleting changes that size, so the next step raises RuntimeError: dictionary changed size during iteration. Sets behave the same way.

solid answer

~40 s

Hash-based containers can resize and rehash on insertion, which moves entries to entirely different internal slots, so continuing to walk them from a stale position would silently return duplicates or skip entries. Rather than allow that, CPython's dict and set iterators store the container's size at creation and compare it on every `__next__`; a mismatch raises `RuntimeError: dictionary changed size during iteration`, or `Set changed size during iteration` for a set. Note precisely what is banned: **size** changes. Reassigning the value of a key that already exists is fine, because the size does not move. The standard fixes are to iterate a materialised snapshot of the keys with `for k in list(d)`, to collect the keys to drop into a list and delete them after the loop, or to rebuild with a dict comprehension.

code

python · 11 lines
python
totals = {"a": 1, "b": 2, "c": 0}

for key in totals:
    totals[key] = 0          # allowed: size never changes

try:
    for key in totals:
        if totals[key] == 0:
            del totals[key]  # size changes
except RuntimeError as exc:
    print(exc)               # dictionary changed size during iteration

go deeper

for a junior

Recognise the message 'dictionary changed size during iteration' and know the one-line fix: loop over list(d) when the body inserts or deletes keys. Remember that assigning to an existing key is fine.

for a middle

Explain the mechanism: the iterator records the size at creation and re-checks it each step, because inserting into a hash table can rehash and move every entry. Distinguish size changes from value changes and give both the snapshot and rebuild fixes.

for a senior

Point out that the exception fires on the next step, leaving the mapping half-pruned, and that the same error appears when a different thread mutates a shared mapping. Prescribe two-phase deletion plus the lock or snapshot rather than a retry.

for a principal

Frame it as a shared-mutable-state question: decide whether long-lived registries should be rebuilt and swapped atomically instead of pruned in place, and what the codebase's convention is for handing out views onto mutable mappings.

## The guard, precisely `dict` and `set` are open-addressing hash tables. A `dict` object tracks how many entries it currently holds; when you call `iter(d)`, the iterator stores a copy of that count alongside a cursor into the table. Every call to `__next__` compares the stored count against the container's current count. If they differ, iteration stops with: ``` RuntimeError: dictionary changed size during iteration ``` A set produces the parallel message, `Set changed size during iteration`, and the view iterators from `dict.keys()`, `dict.values()` and `dict.items()` all carry the same guard. ## Why hash containers need the guard and lists do not Inserting into a hash table can trigger a resize: CPython allocates a larger table and re-inserts every entry into it. After that, the cursor an iterator holds refers to a slot in a table that no longer exists in the same shape. Without the check you would get arbitrary results — entries yielded twice, entries never yielded, or a walk over memory that has been freed. Because the failure mode is unbounded rather than merely surprising, CPython pays for a cheap integer comparison per step and turns it into an exception. A list, by contrast, is a contiguous array walked by index. Mutating it during iteration is *defined*: elements shift, the index keeps climbing, and you silently skip or repeat. Wrong, but memory-safe — so list iteration carries no guard at all. That asymmetry is the point of the question: the same class of mistake is loud on a dict and silent on a list. ## What is allowed The guard watches the size, not the contents. This is legal and common: ```python for key in totals: totals[key] = 0 # rebinding an existing key: size unchanged ``` These are not: ```python for key in totals: totals[key + "_raw"] = 0 # insert -> RuntimeError for key in totals: del totals[key] # delete -> RuntimeError ``` There is one nasty corner. Because the check is a size comparison, deleting one key *and* inserting another in the same pass leaves the size equal and slips past that particular check. What you then get is undefined-in-practice behaviour rather than an error: the loop may visit keys you inserted during it, or miss originals entirely. Do not read "no exception" as "supported"; treat the guard as a smoke alarm, not a proof of correctness. Note also that the exception surfaces on the *next* step, not at the moment of mutation. By then the first mutation has already been applied, so a loop that dies this way leaves the container partly modified. Any cleanup pass built this way is not merely broken, it is broken halfway. ## The fixes, and when each is right **Snapshot the keys.** `for k in list(d):` materialises the keys into a real list first, so the loop walks something nobody is mutating and the body is free to delete. It costs one list of N references, which is usually nothing next to the dict itself. This is the smallest edit to broken code and the one to reach for by default. **Two phases.** Collect what to remove, then remove it: ```python stale = [k for k, v in registry.items() if v is None] for k in stale: del registry[k] ``` Slightly longer than the snapshot form, and better when the decision and the deletion deserve to be readable separately, or when you want to log or count what was dropped. **Rebuild.** `d = {k: v for k, v in d.items() if v is not None}` is a single linear pass with no mutation at all, and since Python 3.7 dicts preserve insertion order, so the rebuilt mapping keeps the surviving keys in their original order. The catch is identity: this binds a *new* dict to the name. If other code holds a reference to the old one, either mutate in place instead, or clear and repopulate the original object. **`dict.pop` with a default.** When you are removing keys chosen elsewhere, `d.pop(k, None)` removes without raising if the key is already gone — useful in cleanup paths where two code paths may both prune. ## Concurrency wears the same error The guard does not know *who* changed the size. If one thread iterates a shared dict while another inserts into it, the iterating thread raises `RuntimeError` — intermittently, under load, in a place that looks correct on the page. Diagnosing "dictionary changed size during iteration" in a traceback where the visible loop body plainly does not mutate the dict is the signature of exactly this. The fixes are the same snapshot idiom (`list(d.items())` while holding the lock that guards the mapping) or a proper `threading.Lock` around the whole traversal. ## What a strong answer sounds like Name the mechanism (a size counter recorded at iterator creation and re-checked per step), explain why hash tables need it and lists do not, distinguish value reassignment (legal) from insertion and deletion (not), mention that the container is left partly mutated when the exception fires, and give the snapshot or two-phase fix rather than merely wrapping the loop in `try`/`except`.

  • Does the same rule apply to a set, and to dict.items()?
    Yes. A set iterator raises `RuntimeError: Set changed size during iteration`, and the view iterators from `dict.keys()`, `dict.values()` and `dict.items()` all carry the same size guard as the dict iterator, because they walk the same table. The idiom is the same: iterate `list(d.items())` or `set(s)` when the body must add or remove members.
  • Why does deleting one key and inserting another in the same pass not raise?
    The guard compares sizes, and a delete plus an insert leaves the size unchanged, so that particular check passes. The traversal is nonetheless walking a table that may have been rehashed, so results are unreliable: the loop can visit keys inserted during it or miss originals. Absence of the exception is not a guarantee of correctness, so use a snapshot anyway.
  • The dict is only mutated by another thread. Why does my loop raise it?
    The guard checks the container's size, not who changed it. A concurrent insertion from another thread makes the iterating thread raise, which is why this shows up intermittently under load in a loop whose own body clearly does not mutate the mapping. Fix it by taking the lock that protects the mapping and iterating a materialised snapshot such as `list(d.items())`, not by retrying.

It is a headcount taken at the door: the usher notes how many people are in the room and re-counts at every row, so anyone entering or leaving mid-inspection aborts the count — but two people swapping places goes unnoticed.

saying these in an interview costs you the question

  • Says any change to a dict during iteration raises
  • Thinks assigning to an existing key raises RuntimeError
  • Expects the same exception from a list
  • Wraps the loop in try/except and calls it fixed
  • Assumes no exception means the traversal was correct
  • Believes the dict is untouched when the error fires

context