Repository navigation
dir() can raise RuntimeError: dictionary changed size during iteration on the free-threaded build #157217
Description
Activity
- addedtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
on Sep 9, 2026 - addedinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)
on Sep 9, 2026 Analysis & Proposed Fix:
The issue diagnosis is spot on. When dir() calls merge_class_dict(), classdict is a mappingproxy (PyDictProxy_Type). Because PyAnyDict_Check(b) evaluates to false for proxies in dict_merge() (Objects/dictobject.c), CPython falls back to the generic PyMapping_Keys() iteration path, bypassing the critical section on the underlying dictionary.
Proposed Solution:
We can handle mappingproxy objects directly in dict_merge() by unwrapping the proxy to its underlying mapping before performing the critical section lock and merge:Check if PyDictProxy_Check(b) is true.
Extract the underlying dict via ((PyDictProxyObject *)b)->mapping.
Pass the underlying dict to Py_BEGIN_CRITICAL_SECTION2(a, underlying_dict) and run dict_dict_merge().
This will fix thread-safety for dir(), dict(vars(cls)), and {**vars(cls)} in free-threaded builds without adding per-iteration lock overhead in Python land.
I can submit a PR with this fix and a regression test for test_free_threaded if the core devs agree with this approach.
- added 11 commits that reference this issue
on Sep 10, 2026 2 remaining items
This doesn't look like a bug to me. The interpreter raises
RuntimeError: dictionary changed size during iterationbecause that's precisely what happens in the MRE. The fact that in a with-GIL build this cannot be exercised because of the lack of parallelism doesn't make this a bug.This doesn't look like a bug to me. The interpreter raises
RuntimeError: dictionary changed size during iterationbecause that's precisely what happens in the MRE. The fact that in a with-GIL build this cannot be exercised because of the lack of parallelism doesn't make this a bug.So if this isn't a bug/isn't something that should be fixed, what am I misinterpreting in the docs section I mentioned?
From the docs you mention:
See Thread Safety Guarantees for the guarantees provided by built-in types.
Pointing to:
To safely iterate over a dictionary that may be modified by another thread, iterate over a copy:
# Make a copy to iterate safely for key, value in d.copy().items(): process(key)
Consider external synchronization when sharing dict instances across threads.
I have no idea why you have an issue with this case specifically, is it a problem that came up as a bug report in a typing library? Or is it just theoretical? The problem of concurrently calling
dir()doesn't look like a big issue to me.Pointing to:
To safely iterate over a dictionary that may be modified by another thread, iterate over a copy:
# Make a copy to iterate safely for key, value in d.copy().items(): process(key)
I believe this make sense if you are iterating over the items yourself using
items(), butdir()is a builtin, it would feel odd to me to hold an external lock every time one wants to usedir()on a class.is it a problem that came up as a bug report in a typing library? Or is it just theoretical?
It is theoretical (in the sense that I did not encounter the issue directly, but as seen in repro can still happen in practice), which I found when investigating other free-threaded race conditions in Pydantic. I think this is pretty rare in practice.
PyDict_Update()(used bytype.__dir__()) documentation states:In the free-threaded build, when b is a
dict(with the standard iterator), both a and b are locked for the duration of the operation. When b is a non-dict mapping, only a is locked; b may be concurrently modified by another thread.So in some way, the thread safety concern is documented (in our case, b would be the
__dict__of thedir()argument). But havingdir()usingPyDict_Update()is an implementation detail, anddir()could very much go through a different variant where it holds a lock for both dicts.
Bug report
Bug description:
I believe there is an issue in the free-threaded build when using
dir(). Apologies if this isn't considered an issue. I read https://docs.python.org/3/howto/free-threading-python.html#thread-safety and my understanding of:is that this is something that could be fixed. Possibly this was missed when implementing #114508.
Calling
dir()on a class (or on an instance of it) can fail withRuntimeError: dictionary changed size during iterationif another thread concurrently performs a read that lazily stores something in the class__dict__. The most common such read on 3.14+ is the first access to__annotations__(e.g. throughtyping.get_type_hints()), which stores__annotations_cache__on the class.Same can happen with e.g.
copy.copy(), which will set__slotnames__viacopyreg._slotnames().MRE
Analysis
AI analysis pointed me at the following. I'm not knowledgeable to know if this is the actual issue, but can help for initial debugging.
The reader:
dir()type.__dir__()andobject.__dir__()collect names withmerge_class_dict(), which fetchescls.__dict__and merges it into a fresh dict withPyDict_Update():cpython/Objects/typeobject.c
Lines 6965 to 6982 in 894af95
cls.__dict__is amappingproxy, not adict, sodict_merge()doesn't take the fast path (which holds the critical sections of both dicts). It takes the generic path instead, which only holds the critical section of the destination dict:cpython/Objects/dictobject.c
Lines 4309 to 4324 in 894af95
The generic path gets the keys with
PyMapping_Keys(), which for a non-dict calls.keys()and turns the returneddict_keysview into a list by iterating over it:cpython/Objects/abstract.c
Lines 2433 to 2468 in 894af95
That iteration over the class
__dict__happens without holding its critical section, and the dict iterator checks the size at every step, so any concurrent insertion into the class__dict__raisesRuntimeError.The writer: a lazy cache stored on the class by a read
The
type.__annotations__getter stores the evaluated annotations as__annotations_cache__in the class__dict__on first access:cpython/Objects/typeobject.c
Lines 2194 to 2201 in 894af95
If the class has no
__annotate__function, thetype.__annotate__getter (called by the above) also stores__annotate_func__ = None:cpython/Objects/typeobject.c
Lines 2086 to 2092 in 894af95
Other stdlib operations do the same kind of lazy insertion into a class
__dict__, so the issue is not specific to annotations:copy.copy(),copy.deepcopy()and pickling of an instance store__slotnames__on the class, on every version, through_PyType_GetSlotNames()/copyreg._slotnames():cpython/Objects/typeobject.c
Lines 7790 to 7795 in 894af95
cpython/Lib/copyreg.py
Lines 155 to 158 in 894af95
The first instantiation of a concrete subclass of a
typing.Protocolreplaces its__init__():cpython/Lib/typing.py
Lines 1925 to 1931 in 894af95
Other affected readers
Everything relying on
dir()is affected, e.g.inspect.getmembers()andinspect.classify_class_attrs()(which additionally iterate overbase.__dict__.items()themselves), orunittest.mock's autospec.dict(vars(cls))(used e.g. bytyping.get_type_hints()for the evaluation locals) goes through the samedict_merge()generic path:cpython/Lib/typing.py
Line 2451 in 894af95
Possible fix
In
dict_merge()(or inPyMapping_Keys()), unwrap amappingproxywhose underlying mapping is a dict and use the locked dict-to-dict path. This would fixdir(),dict(vars(cls)),{**vars(cls)}and everything built on them at once. The pure-Python loops overbase.__dict__.items()ininspectandenum.Enum.__dir__would still need to iterate over a copy.CPython versions tested on:
3.15
Operating systems tested on:
macOS
Linked PRs