Two collections, the same mistake, two very different symptoms. Only one of them tells you.
sessions = {'a': 1, 'b': 2, 'c': 3}
for key in sessions:
del sessions[key]
# RuntimeError: dictionary changed size during iteration
items = [1, 2, 3, 4]
for item in items:
items.remove(item)
print(items) # -> [2, 4] no error, and half of them survived
# The fix that works and still edits the caller's data
for key in list(sessions):
del sessions[key]
- The dictionary is protecting you; the list is not
A dict iterator checks the size on every step and raises the moment it changes, so the bug is loud and immediate. A list iterator just holds an index — remove an item and everything after it shifts down, so the next one is skipped and the loop finishes with a straight face. The RuntimeError is the friendlier of the two behaviours by a wide margin.
- list(sessions) makes a snapshot of the keys
Wrapping the dictionary in list() copies the key names before the loop starts, so deleting from the dictionary no longer disturbs what is being iterated. It is the standard fix and it is correct — note it copies the keys, not the sessions, so it is cheap even on a large dictionary.
- Not deleting at all is better than deleting safely
A comprehension that keeps the survivors has nothing to iterate and modify, so the question cannot arise — and it returns a new dictionary instead of editing the caller’s, which is usually what the caller wanted anyway. When a bug comes from mutating something, the most durable fix is frequently to stop mutating it.
The error is telling you exactly what happened: the dictionary changed while something was walking it. There are two ways out — walk a copy, or stop changing it.
Read the contract again before reaching for list(sessions): it asks for a new dictionary and says the caller’s is not to be modified. That rules out deleting entirely.
A dict comprehension over sessions.items(), keeping the entries whose expiry is after now. Check for the missing key before you compare — a bare session['expires'] raises KeyError, which is the wrong type and names the key rather than the session.
def drop_expired(sessions, now): for key, session in sessions.items(): if 'expires' not in session: raise ____(f’session {key!r} has no expiry') return { key: session for key, session in sessions.____() if session['expires'] ____ now }
def drop_expired(sessions, now):
for key, session in sessions.items():
if "expires" not in session:
raise ValueError(f"session {key!r} has no expiry")
# Building a new dictionary rather than deleting from the old one:
# nothing is being modified while it is iterated, and the caller's
# store is left alone.
return {
key: session
for key, session in sessions.items()
if session["expires"] > now
}
list(sessions) plus del would also have worked, and would have left a function whose correctness depends on somebody remembering why the list() is there — delete it in a tidy-up and the crash comes back. A comprehension has nothing to remember: there is no mutation to get wrong. Prefer the fix that removes the failure mode over the one that dodges it.
The new dictionary holds the same session objects, not copies — so a caller who updates one still sees the change through both. Copying them would be surprising and expensive; the contract says the *dictionary* is new, which is a different promise from the contents being new.
A check inside the comprehension would make it unreadable, and relying on the KeyError from session['expires'] gives the caller an exception naming 'expires' rather than the session that lacks it. One short loop up front costs a traversal and buys an error somebody can act on.
If the dictionary is enormous and mostly survivors, building a new one doubles the memory for a moment — there, deleting from a snapshot of the keys is the right trade, and the function should say in its name that it modifies in place. For expiring caches specifically, do not write either: cachetools.TTLCache handles expiry on access, and Redis does it with EXPIRE and no cleanup pass at all. A periodic sweep over every session is a sign the storage is doing less than it could.
- Write the in-place version as drop_expired_inplace and make its name and docstring carry the warning that the return value does not.
- Return the expired sessions as well as the survivors, so the caller can log what was cleared.
- Try the same bug with a list and a for loop, and confirm for yourself that it skips items rather than raising.