skip to content

Why does a Python dict raise RuntimeError when a payroll import deletes stale keys mid-iteration, and how do you fix it?

level: seniorimportance: should knowfreq 42%

answer

  1. A fail-fast check inside the iterator
  2. It compares one number each step
  3. Deleting mid-loop is the trigger
  4. Iterate a snapshot, not the dict

basics

~20 s

A dict iterator records the dict's size and re-checks it on every step, so deleting or inserting a key makes the next step raise RuntimeError. Iterate a snapshot such as list(d), or rebuild the dict with a comprehension, and mutate from there.

solid answer

~50 s

`for k in d` creates an iterator that stores the dict's size; every `__next__` compares the stored size with the current one and raises `RuntimeError: dictionary changed size during iteration` if they differ. The guard exists because deleting leaves tombstones and inserting can resize the hash table, moving every entry out from under the iterator's position. It fires the same way for `d.keys()`, `d.values()` and `d.items()`, because a view is a live window rather than a copy. Reassigning a value for an existing key is fine - the size does not change. The fixes are to iterate a snapshot (`for k in list(d)`), rebuild with `{k: v for k, v in d.items() if keep(v)}`, collect the doomed keys first and delete afterwards, or drain with `while d: d.popitem()`. Catching the RuntimeError is not a fix: the loop has already ended.

code

python · 12 lines
python
rows = {"e1": "active", "e2": "stale", "e3": "stale"}
try:
    for emp_id, status in rows.items():
        if status == "stale":
            del rows[emp_id]
except RuntimeError as exc:
    print(exc)

for emp_id in list(rows):
    if rows[emp_id] == "stale":
        del rows[emp_id]
print(rows)

go deeper

for a junior

Recognise the message 'dictionary changed size during iteration' and know the reflex fix: loop over list(d) instead of d, or build a new dict, rather than deleting from the dict you are currently walking.

for a middle

Explain the mechanism - the iterator records the size and re-checks it each step - and why the hash table's tombstones and resizes make that guard necessary. Say which mutations are safe and which are not.

for a senior

Diagnose it in a real job: name the four safe rewrites and pick between them on cost and on whether other references share the dict, and point out that catching the exception ends the loop early and silently leaves work undone.

for a principal

Treat it as a shared-mutable-state question. Decide who is allowed to mutate a long-lived in-memory mapping, whether readers get a rebuilt replacement swapped in atomically instead of in-place edits, and where that boundary is enforced.

The error is `RuntimeError: dictionary changed size during iteration`, and it is a deliberate guard rather than an accident. **The mechanism.** When you write `for key in d`, Python calls `iter(d)` and gets a `dict_keyiterator`. That iterator stores a reference to the dict and records the dict's current size. On every `__next__` call it re-reads the dict's size and compares it with the recorded one; if they differ, it raises `RuntimeError` instead of continuing. The same guard sits in the value and item iterators, so `for k in d.keys()`, `for v in d.values()` and `for k, v in d.items()` all behave identically — obtaining a view first does not help, because a view is a live window on the same dict, not a copy. The guard exists because a dict is a hash table. Deleting an entry leaves a tombstone; inserting may push the table past its load factor and trigger a resize that reallocates the entries array and moves every entry. An iterator holds a position into that array. If the array is rebuilt underneath it, the position now points somewhere meaningless, and without the check you would get silently duplicated keys, silently skipped keys, or memory unsafety. Fail-fast is the cheaper contract. **What does *not* raise.** Reassigning the value of a key that already exists does not change the size and does not resize the table, so this is legal and safe: ```python for key in totals: totals[key] = round(totals[key], 2) ``` Only adding or removing keys trips the guard. **The scenario.** A nightly payroll CSV import loads each row into a dict keyed by employee id, and the same dict is then served to lookups by the payroll API, which peaks around 1,200 requests per minute. A cleanup pass was written as `for emp_id, row in rows.items(): if row.stale: del rows[emp_id]`, and it dies on the first stale row. The team's first instinct — wrapping the loop body in `try` / `except RuntimeError: continue` — is the wrong fix twice over: the exception comes from the iterator, not the body, so the loop simply ends early and leaves stale rows in place, and the ones it processed are whatever happened to come first. **The fixes, in order of preference.** 1. **Rebuild.** `rows = {k: v for k, v in rows.items() if not v.stale}` — one pass, no mutation of the dict being read, and because dicts are insertion-ordered (guaranteed since Python 3.7) the survivors keep their original CSV order. This is the fix to reach for when other code depends on the order, which it did here: the export step wrote columns in `rows` order. 2. **Snapshot the keys.** `for emp_id in list(rows):` materializes a list of keys first, so the loop iterates the list while mutating the dict. Slightly cheaper in memory than rebuilding for a large dict with few deletions, and the right choice when you must mutate in place because other references point at the same dict object. 3. **Collect then delete.** `stale = [k for k, v in rows.items() if v.stale]` followed by `for k in stale: del rows[k]`. Two passes, but the intent is explicit and the predicate is testable on its own. 4. **Drain.** `while rows: k, v = rows.popitem()` never creates an iterator at all, so the guard cannot fire. Use it when you want to consume the dict entirely. **The check is not a correctness guarantee.** It compares sizes only. A loop that deletes one key and inserts another on the same pass leaves the size unchanged, slips past the guard, and iterates a rearranged table with no error at all: ```python rows = {"a": 1, "b": 2, "c": 3} for key in rows: del rows[key] rows[key.upper()] = 1 ``` That runs clean and produces a dict of entirely different keys. Do not treat the absence of a `RuntimeError` as proof that mutation during iteration was safe. For the same reason, the guard is a debugging aid, not a synchronization mechanism: it says nothing about correctness when a second thread is mutating the dict. **Neighbouring behaviours worth knowing.** A `set` raises the parallel `RuntimeError: Set changed size during iteration`. A `list` raises nothing at all — `for x in L: L.remove(x)` walks by index, so removing an element shifts the rest down and the loop silently skips every other item, which is the quieter and nastier bug. Given the choice, the dict's loud failure is the friendlier design.

  • Is reassigning the value of an existing key during iteration safe?
    Yes. `for k in d: d[k] = round(d[k], 2)` changes no key and no size, so the guard never fires and the hash table is never resized or rehashed. Only inserting a new key or deleting one does that. This is the standard way to normalise every value in place without building a second dict.
  • Why does the same pattern on a list not raise, and what happens instead?
    A list iterator walks an integer index rather than checking size, so `for x in L: L.remove(x)` shifts later elements down and the loop silently skips every other one - no error, wrong result. A `set` does guard itself, raising `RuntimeError: Set changed size during iteration`. The dict's loud failure is the friendlier of the three.
  • Can a delete and an insert in the same pass get past the size check?
    Yes, and that is the important caveat: the guard compares sizes only. Deleting one key and adding another on the same iteration leaves the size unchanged, so iteration continues over a rearranged table and can yield duplicates or skip entries with no exception at all. Never read the absence of a RuntimeError as proof that the mutation was safe.

saying these in an interview costs you the question

  • Wraps the loop body in try/except RuntimeError and calls it fixed
  • Claims the RuntimeError means the dict is corrupted
  • Thinks iterating `d.keys()` avoids the error because keys() copies
  • Believes updating an existing key's value during iteration also raises
  • Assumes the guard catches every mutation, including a balanced delete plus insert
  • Says a list raises the same error when you remove during iteration

context