Skip to content

Fix: Python RuntimeError: dictionary changed size during iteration

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Python RuntimeError dictionary changed size during iteration caused by modifying a dict while looping over it, with solutions using copies, comprehensions, and safe patterns.

The Iterator Caught You Mutating

Personally, this is one of my favorite Python error messages: it tells you exactly what went wrong and why, and it almost always points at one specific pattern. I ran into it most often early in my career when I was filtering a config dict in place, and Python rightly told me to stop. You run Python code and get:

RuntimeError: dictionary changed size during iteration

Or in older Python versions:

RuntimeError: dictionary changed size during iteration

You modified a dictionary (added or removed keys) while iterating over it with a for loop. Python detects this and raises a RuntimeError to prevent undefined behavior.

Quick Reference Before You Dive In

If you arrived here from Google with a fresh traceback, the five facts that resolve roughly 90 percent of cases:

  1. Reassigning existing values is fine. Adding or removing keys is not. d[k] = new_value does NOT raise; del d[k] and d[new_key] = x do. The check fires only on size changes. The Python dict docs and the mapping protocol reference are the canonical sources.
  2. Iterate over a snapshot when you need to mutate. for k in list(d): materializes the keys into a list; the for-loop walks the list, so the live dict can grow or shrink freely. This is the workhorse fix.
  3. For filter operations, dict comprehension is cleaner than in-place mutation. d = {k: v for k, v in d.items() if keep(v)} is the Pythonic form. It is also faster than collecting keys and calling del in a loop.
  4. Watch for indirect mutation from functions called inside the loop. Many production cases are NOT the obvious del d[k]; they are process(d, k) where process silently mutates the dict. Read the body of every function called from a dict-iteration loop.
  5. In asyncio code, await inside the loop releases control to other coroutines that can mutate the dict. Snapshot before the loop or move the mutation out of the awaited coroutine.

The rest of this article walks through each case in detail, plus the failure modes most other guides skip.

How CPython’s Size-Change Detector Actually Works

When you iterate over a dictionary with for key in my_dict, Python creates an internal iterator that tracks the dictionary’s state. If the dictionary’s size changes (keys added or removed) during iteration, the iterator detects the inconsistency and raises RuntimeError.

This is a safety mechanism. In languages without this protection, modifying a collection during iteration leads to skipped items, infinite loops, or crashes. The CPython implementation tracks a version counter on every dict object. The iterator stores the version it was created with; on every step it compares the current version to the stored one. If they differ and the difference was caused by an insert or delete (not a value-only update), RuntimeError is raised before the iterator can return garbage.

The check is intentionally restrictive: it only fires on size changes, not on value mutations. That is why you can safely reassign d[k] = new_value inside the loop but you cannot do d[new_key] = x or del d[k]. The runtime treats those as structural modifications that invalidate the iteration order.

This triggers the error:

my_dict = {"a": 1, "b": 2, "c": 3}

for key in my_dict:
    if my_dict[key] < 2:
        del my_dict[key]  # RuntimeError!

This does NOT trigger the error:

my_dict = {"a": 1, "b": 2, "c": 3}

for key in my_dict:
    my_dict[key] = my_dict[key] * 2  # OK: modifying values, not adding/removing keys

Modifying values is fine. Adding or removing keys is not.

Common causes:

  • Deleting keys during iteration. Removing entries that do not meet a condition.
  • Adding keys during iteration. Inserting new entries based on existing ones.
  • Indirect modification. A function called inside the loop modifies the same dictionary.
  • Multi-threaded access. Another thread modifies the dictionary while the current thread iterates.

Version History That Changes the Failure Mode

The dict implementation in CPython has changed enough over recent versions that the same code can behave subtly differently depending on which interpreter you run. Knowing which Python you are on helps explain why a bug surfaces here but not on a colleague’s machine.

  • Python 3.6 (Dec 2016):compact, ordered dict. Insertion order became an implementation detail in CPython 3.6. Iteration order started reflecting insertion order, which made dict iteration debuggable but also made size-change bugs more reproducible (you no longer got randomized ordering masking the issue).
  • Python 3.7 (Jun 2018):ordered dict guaranteed by the language. What was an implementation detail in 3.6 became a language guarantee in 3.7. Any code that relied on “iteration order is random so a missed key won’t matter” stopped being defensible. RuntimeError on size change has been the documented behaviour since this version onward.
  • Python 3.8 (Oct 2019):dict reversal and walrus operator. reversed(d) became valid. The walrus operator (:=) made it easier to write expressions that capture and check a value while iterating, which led to a new class of subtle “I modified during a comprehension” bugs in code that wasn’t using it carefully.
  • Python 3.9 (Oct 2020):dict merge and update operators (|, |=). These give you a cleaner way to build a new dict from an existing one without mutating during iteration. Code on 3.9+ can often replace a problematic in-place loop with d = d | {k: v for k, v in ... }.
  • Python 3.10 (Oct 2021):structural pattern matching. match over a dict captures keys at the time the case is evaluated. It does not by itself trigger this error, but combining match with a loop that mutates the matched dict is a new pitfall.
  • Python 3.12 (Oct 2023):per-interpreter GIL groundwork and small dict layout changes. The detection mechanism is unchanged, but error messages and tracebacks were cleaned up so the exact line where iteration was invalidated is easier to locate.
  • Python 3.13 (Oct 2024):experimental free-threaded build (PEP 703). In the no-GIL build, concurrent modification of a dict from multiple threads becomes much more likely to surface as RuntimeError instead of being masked by the GIL serialising writes. Code that “worked” under the GIL by accident will start failing here.

If you support multiple Python versions, write defensive code that creates an explicit snapshot (list(d) or a dict comprehension). It works identically across every version listed above.

When to Use Which Fix

The next eight sections cover the fixes in detail. The table below maps your situation to the recommended fix.

Your situationRecommended fixWhy
Removing keys by simple conditionFix 1: for k in list(d):Simplest, snapshots keys
Filtering to a new dictFix 2: dict comprehensionMost Pythonic, cleanest
Complex two-phase find-then-modifyFix 3: collect keys, then deleteSeparates discovery from mutation
A function called inside the loop mutates the dictFix 4: pass snapshot or collect updates externallyIndirect mutation is the hidden cause
Removing specific keys by nameFix 5: dict.pop(key, default)Avoids KeyError on missing keys
Same pattern on a setFix 6: set comprehension or for x in list(s):Sets raise the same error
Multiple threads accessing one dictFix 7: threading.Lock or QueueGIL does not protect dict iteration
Using defaultdict and accidentally creating keysFix 8: .get() instead of d[key]Subscript on defaultdict creates the key

If multiple rows apply, pick the topmost one.

Fix 1: Iterate Over a Copy of the Keys

Create a copy of the keys before iterating:

Broken:

users = {"alice": 1, "bob": 5, "charlie": 0, "dave": 3}

for user in users:
    if users[user] == 0:
        del users[user]  # RuntimeError!

Fixed: copy keys with list():

users = {"alice": 1, "bob": 5, "charlie": 0, "dave": 3}

for user in list(users):  # list() creates a snapshot of the keys
    if users[user] == 0:
        del users[user]  # Safe: iterating over the copy

print(users)  # {"alice": 1, "bob": 5, "dave": 3}

list(users) creates a list of keys at that point in time. The for loop iterates over this static list, not the live dictionary, so modifications are safe.

Also works with .keys(), .values(), .items():

for key, value in list(users.items()):
    if value == 0:
        del users[key]

A small efficiency note: list(my_dict) is equivalent to list(my_dict.keys()). Both create a snapshot of the keys. I default to the first form because it is shorter and a hair faster, but the second reads more explicitly in code reviews.

Fix 2: Use Dictionary Comprehension

Build a new dictionary instead of modifying the existing one:

users = {"alice": 1, "bob": 5, "charlie": 0, "dave": 3}

# Keep only users with non-zero values
users = {user: score for user, score in users.items() if score != 0}

print(users)  # {"alice": 1, "bob": 5, "dave": 3}

This is the most Pythonic approach for filtering dictionaries. It creates a new dictionary and reassigns the variable.

For transforming values:

prices = {"apple": 1.0, "banana": 0.5, "cherry": 2.0}

# Apply 10% discount to all prices
prices = {item: price * 0.9 for item, price in prices.items()}

For conditional transformation:

data = {"a": 1, "b": -2, "c": 3, "d": -4}

# Keep positives, double their value
data = {k: v * 2 for k, v in data.items() if v > 0}
# {"a": 2, "c": 6}

Fix 3: Collect Keys to Delete, Then Delete

Separate the “find” phase from the “modify” phase:

config = {"debug": True, "verbose": True, "timeout": 30, "temp_file": "/tmp/x"}

# Phase 1: Collect keys to remove
keys_to_remove = [key for key in config if key.startswith("temp_")]

# Phase 2: Remove them
for key in keys_to_remove:
    del config[key]

This pattern is especially useful when the deletion logic is complex or involves multiple conditions:

inventory = {"widget_a": 0, "widget_b": 15, "widget_c": 0, "widget_d": 8}

# Find all out-of-stock items
out_of_stock = [item for item, count in inventory.items() if count == 0]

# Remove them
for item in out_of_stock:
    del inventory[item]

print(inventory)  # {"widget_b": 15, "widget_d": 8}

Fix 4: Fix Indirect Modifications

A function called inside the loop might modify the dictionary:

Broken:

def process_item(data, key):
    # This function adds new keys to the same dict!
    if data[key] > 10:
        data[f"{key}_processed"] = True  # Modifies the dict being iterated!

items = {"a": 5, "b": 15, "c": 20}

for key in items:
    process_item(items, key)  # RuntimeError!

Fixed: use a copy or collect modifications:

def process_item(data, key):
    if data[key] > 10:
        return {f"{key}_processed": True}
    return {}

items = {"a": 5, "b": 15, "c": 20}
updates = {}

for key in list(items):
    updates.update(process_item(items, key))

items.update(updates)  # Apply all modifications after iteration

The most expensive bug of this shape I have ever shipped was indirect: a helper that “just returned a derived value” actually wrote a cache key into the same dict its caller was walking. The traceback pointed at the for-loop, which was untouched, and I lost most of an afternoon before I read the helper. When you hit this error and the loop body looks clean, read the body of every function it calls.

Fix 5: Use dict.pop() with a Separate Collection

For removing specific keys based on lookups:

cache = {"user:1": "Alice", "user:2": "Bob", "temp:1": "session", "temp:2": "data"}

# Remove all temporary entries
temp_keys = [k for k in cache if k.startswith("temp:")]
for key in temp_keys:
    cache.pop(key, None)  # pop with default avoids KeyError

print(cache)  # {"user:1": "Alice", "user:2": "Bob"}

pop() vs del:

# del raises KeyError if key doesn't exist
del my_dict["missing_key"]  # KeyError!

# pop with default returns the default value instead
my_dict.pop("missing_key", None)  # Returns None, no error

Fix 6: Fix Sets and Other Collections

The same error occurs with sets:

numbers = {1, 2, 3, 4, 5}

for n in numbers:
    if n % 2 == 0:
        numbers.discard(n)  # RuntimeError: Set changed size during iteration

Fixed:

numbers = {1, 2, 3, 4, 5}

# Set comprehension
numbers = {n for n in numbers if n % 2 != 0}

# Or iterate over a copy
for n in list(numbers):
    if n % 2 == 0:
        numbers.discard(n)

# Or use set operations
numbers -= {n for n in numbers if n % 2 == 0}

For lists, Python does not raise this error, but modifying a list during iteration causes skipped elements:

items = [1, 2, 3, 4, 5]

# This silently skips elements, no error, but wrong results!
for item in items:
    if item % 2 == 0:
        items.remove(item)

print(items)  # [1, 3, 5]: looks right but only by coincidence!

Always use list() copies or comprehensions for safe iteration.

Fix 7: Fix Multi-Threaded Dictionary Access

If multiple threads access the same dictionary, modifications in one thread can cause this error in another:

Broken:

import threading

shared_data = {"count": 0}

def writer():
    for i in range(1000):
        shared_data[f"key_{i}"] = i

def reader():
    for key in shared_data:  # RuntimeError if writer adds keys concurrently
        _ = shared_data[key]

t1 = threading.Thread(target=writer)
t2 = threading.Thread(target=reader)
t1.start()
t2.start()

Fixed: use a lock:

import threading

shared_data = {"count": 0}
lock = threading.Lock()

def writer():
    for i in range(1000):
        with lock:
            shared_data[f"key_{i}"] = i

def reader():
    with lock:
        snapshot = dict(shared_data)  # Copy under lock
    for key in snapshot:
        _ = snapshot[key]

Fixed: use a thread-safe alternative:

from collections import defaultdict
from queue import Queue

# For producer-consumer patterns, use Queue instead of shared dicts
work_queue = Queue()

Fix 8: Use defaultdict Safely

collections.defaultdict creates keys on access, which can cause the same error:

Broken:

from collections import defaultdict

counts = defaultdict(int, {"a": 1, "b": 2, "c": 3})

for key in counts:
    # Accessing a missing key with defaultdict creates it!
    if counts[key + "_total"] > 0:  # Creates "a_total", "b_total", etc.!
        pass  # RuntimeError!

Fixed: use .get() or in check:

for key in list(counts):
    if counts.get(key + "_total", 0) > 0:  # .get() doesn't create keys
        pass

Or use key in counts to check existence without creating the key.

Stranger Cases I Have Tracked Down

Check for nested dictionary iteration. If you iterate over a parent dictionary and a function modifies a nested dictionary, that is fine; the error only occurs when the dictionary being iterated changes size.

Check for __del__ or __setattr__ side effects. Custom destructors or attribute setters might modify the dictionary as a side effect of other operations.

Use copy.deepcopy() for complex nested structures:

import copy

original = {"a": {"nested": [1, 2, 3]}, "b": {"nested": [4, 5, 6]}}
snapshot = copy.deepcopy(original)

for key in snapshot:
    # Modify original freely
    del original[key]

Check for async callbacks mutating shared state. In asyncio code, you can await inside a for loop. While the coroutine is suspended, another task scheduled on the same event loop can mutate the dict you are walking. The next iteration step then trips RuntimeError even though no thread is involved. Either snapshot the keys before the loop or move the mutation out of the awaited coroutine. For broader asyncio pitfalls, see Fix: Python asyncio RuntimeError: no running event loop.

Check for ORM session expiration. If you iterate over an SQLAlchemy session’s identity_map or a Django queryset that is internally backed by a dict, calling session.commit() or a refresh inside the loop can change the underlying mapping and surface this exact error. Detach the snapshot first.

Check globals(), locals(), and dict of an object. These are live dicts. Mutating attributes on the same object you are iterating its __dict__ over (for example, dynamically renaming attributes inside the loop) will raise. Snapshot with vars(obj).copy() before walking.

Check OrderedDict and ChainMap. Both raise the same error, with slightly different messages. The fix is identical: iterate over a snapshot or rebuild via comprehension. For other surprising Python errors with iterators, see Fix: Python KeyError and Fix: Python IndexError: list index out of range.

What Other Tutorials Get Wrong About This Error

Most Python tutorials list the same fixes but frame them in ways that produce subtle bugs.

They confuse size changes with value mutations. d[k] = new_value is fine; d[new_k] = x and del d[k] raise. Tutorials that say “you cannot mutate a dict during iteration” are inaccurate; you cannot CHANGE THE SIZE. Value updates are safe by design.

They miss the indirect-mutation case. Most production occurrences are not the obvious del d[k] inside the loop; they are process(d, k) where process mutates the dict silently. Tutorials that only show the obvious case leave readers debugging “I am not mutating the dict” when in fact they are, three function calls deep.

They omit the asyncio-coroutine variant. await inside a for-loop suspends the coroutine; another task scheduled on the same event loop can mutate the dict before control returns. The next iteration step raises. Tutorials written for synchronous code never mention this and confuse asyncio users when the bug appears in code that visibly has no mutation.

They equate this with the list-mutation problem. Modifying a list during iteration does NOT raise; it silently skips elements. The two bugs share a root cause (mutating during iteration) but have different symptoms. Tutorials that group them together miss the “no error but wrong results” character of the list case, which is arguably worse than the dict case.

They recommend defaultdict casually for fix patterns. defaultdict[missing_key] CREATES the key, which then triggers this error if you do it inside a loop. Use .get(key, default) or if key in d: to check without creating. Articles that show defaultdict examples inside iteration loops are introducing the bug they claim to fix.

They miss OrderedDict and ChainMap. These raise the same error with slightly different messages. The fix is identical: snapshot or comprehension. Tutorials focused only on plain dict send readers to retry the same broken pattern on these other mapping types.

Frequently Asked Questions

Why does updating a value not raise but adding a key does?

The runtime tracks a separate version counter for size changes only. d[k] = v where k already exists modifies the value in place without changing the dict’s structural layout. d[new_k] = v and del d[k] change the size, which invalidates the iterator’s position assumptions. Modifying values during iteration is safe by design.

Is dict.update(other) safe during iteration?

No, if other adds any new keys. update is a structural modification: any new key insertion changes the size and trips the iterator on the next step. Either snapshot the keys with for k in list(d): before the loop or build a new dict via comprehension and reassign at the end.

Can I add and remove keys “balanced” so the size stays the same?

No. The check is on the version counter, not the net size. Any structural modification increments the counter, regardless of whether the net size changes. Two operations that cancel out (delete a, add b) still raise on the next iteration step.

What about globals() and locals()?

Both are live dicts. Mutating attributes on the same object you are iterating its __dict__ over (for example, dynamically renaming attributes inside the loop) will raise. Snapshot with vars(obj).copy() before walking, or rebuild via comprehension.

Why does the error message vary slightly?

CPython’s error string is dictionary changed size during iteration for dict, Set changed size during iteration for set, OrderedDict mutated during iteration for OrderedDict. The fix is the same: snapshot or comprehension. Subclasses that override __iter__ may produce slightly different messages.

Does this happen on PyPy or other Python implementations?

PyPy raises the same error in most cases but the exact moment of detection differs because PyPy does not use CPython’s version counter. Jython and IronPython historically had different (often more permissive) behavior. Code that relies on “this works on my CPython 3.12” should snapshot defensively for cross-implementation safety.

For TypeError issues with None values inside iteration, see Fix: Python TypeError: ‘NoneType’ object is not subscriptable.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles