Fix: Python RuntimeError: dictionary changed size during iteration
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 iterationOr in older Python versions:
RuntimeError: dictionary changed size during iterationYou 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:
- Reassigning existing values is fine. Adding or removing keys is not.
d[k] = new_valuedoes NOT raise;del d[k]andd[new_key] = xdo. The check fires only on size changes. The Pythondictdocs and the mapping protocol reference are the canonical sources. - 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. - 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 callingdelin a loop. - Watch for indirect mutation from functions called inside the loop. Many production cases are NOT the obvious
del d[k]; they areprocess(d, k)whereprocesssilently mutates the dict. Read the body of every function called from a dict-iteration loop. - In
asynciocode,awaitinside 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 keysModifying 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.
RuntimeErroron size change has been the documented behaviour since this version onward. - Python 3.8 (Oct 2019):
dictreversal 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 withd = d | {k: v for k, v in ... }. - Python 3.10 (Oct 2021):structural pattern matching.
matchover a dict captures keys at the time the case is evaluated. It does not by itself trigger this error, but combiningmatchwith 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
RuntimeErrorinstead 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 situation | Recommended fix | Why |
|---|---|---|
| Removing keys by simple condition | Fix 1: for k in list(d): | Simplest, snapshots keys |
| Filtering to a new dict | Fix 2: dict comprehension | Most Pythonic, cleanest |
| Complex two-phase find-then-modify | Fix 3: collect keys, then delete | Separates discovery from mutation |
| A function called inside the loop mutates the dict | Fix 4: pass snapshot or collect updates externally | Indirect mutation is the hidden cause |
| Removing specific keys by name | Fix 5: dict.pop(key, default) | Avoids KeyError on missing keys |
| Same pattern on a set | Fix 6: set comprehension or for x in list(s): | Sets raise the same error |
| Multiple threads accessing one dict | Fix 7: threading.Lock or Queue | GIL does not protect dict iteration |
Using defaultdict and accidentally creating keys | Fix 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 iterationThe 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 errorFix 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 iterationFixed:
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
passOr 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: joblib Not Working — Parallel Backends, Memory Cache, and Pickling Errors
How to fix joblib errors — Parallel n_jobs slower than expected, Memory cache miss, backend loky vs threading vs multiprocessing, pickling lambda not supported, dump load file size, and pytest interference.
Fix: Marshmallow Not Working — Schema Errors, Load vs Dump, and Field Validation
How to fix Marshmallow errors — Schema not validated on dump, ValidationError messages format, unknown field handling, missing vs default, post_load object construction, and Marshmallow 3 to 4 migration.
Fix: Pipenv Not Working — Lock File Generation, Shell Activation, and Dependency Resolution
How to fix Pipenv errors — pipenv lock takes forever, Pipfile.lock not generated, shell activation broken, no virtualenv created, dependency conflict, and migration to uv or Poetry.
Fix: Copier Not Working — Template Updates, Question Conditions, and Migrations
How to fix Copier errors — copier.yml not found, conditional questions not appearing, update breaks generated project, migrations between versions, Jinja vs YAML escaping, and answers file conflict.