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 Error

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.

Why This Happens

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.

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]

Pro Tip: list(my_dict) is equivalent to list(my_dict.keys()). Both create a snapshot of the keys. The first form is shorter and slightly faster.

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

Common Mistake: Not realizing that a function modifies the dictionary. When debugging this error, check every function called inside the loop to see if any of them add or remove keys from the dictionary being iterated.

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.

Still Not Working?

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.

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