Repository navigation
3.14 regression: slot dataclasses classes leak original class #135228
Description
Activity
- addedtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
on Jun 6, 2025 I think this is because the annotate function for the class holds on to the original class namespace, and that namespace dict contains the descriptors
B.__dict__andB.__weakref__that refer back to the original class. Not too sure how to fix that yet.- added3.14bugs and security fixesbugs and security fixes3.15pre-release feature fixes, bugs and security fixespre-release feature fixes, bugs and security fixes
on Jun 7, 2025 This seems hard to avoid, maybe we should just live with the change? The reason is that the annotation scope needs to hold a reference to the actual class dict (for reasons explained in https://jellezijlstra.github.io/pep695#class-scopes) and the class dict contains two descriptors, for
__dict__and__weakref__, that contain references back to the class object.In Python 3.12 and 3.13 this behavior is already observable if you use a type alias or TypeVar bound in the dataclass, with one of these class definitions:
@dataclass(slots=True) class B: def f[T: int](self, x: T) -> T: ... b: int @dataclass(slots=True) class B: type T = int b: int
Both of these fail the assertion in the post above.
I put up a change in #135230 that fixes this issue, but messing with the descriptors feels like it's probably going to break something else.
- addedstdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directory
on Jun 7, 2025 @JelleZijlstra I think you're right that the leak itself is not a big deal, and we can just live with it.
What is a bigger deal is this:
from dataclasses import dataclass class A: pass @dataclass(slots=True) class B(A): pass print(A.__subclasses__()) # [<class '__main__.B'>, <class '__main__.B'>]
This will break a piece of cattrs and anyone using
__subclasses__()with slotted dataclasses and attrs classes. Maybe we can work around it in the__subclassess__()implementation somehow?release blocker status just to help force consideration. It may wind up deferred.
54 remaining items
- added 3 commits that reference this issue
on Sep 9, 2025 Glad I found this issue. The original example passes on 3.14.2. However, I've just found a new one.
@dataclass(frozen=True, slots=True) class B: b: int gc.collect() assert [B] == [ o for o in gc.get_objects() if hasattr(o, "__name__") and o.__name__ == "B" ]
The difference is
frozen=Truein dataclass call.frozenenables calling function_frozen_get_del_attr.- This function adds methods
__delattr__and__setattr__to the function builder, but it also updates locals of function builder with the reference of the class. - These locals are passed to function
__create_fn__that creates other functions for the class. __delattr__and__setattr__reference the class through closure; hence, making 2 circular references.
Lines 728 to 748 in c461aa9
def _frozen_get_del_attr(cls, fields, func_builder): locals = {'cls': cls, 'FrozenInstanceError': FrozenInstanceError} condition = 'type(self) is cls' if fields: condition += ' or name in {' + ', '.join(repr(f.name) for f in fields) + '}' func_builder.add_fn('__setattr__', ('self', 'name', 'value'), (f' if {condition}:', ' raise FrozenInstanceError(f"cannot assign to field {name!r}")', f' super(cls, self).__setattr__(name, value)'), locals=locals, overwrite_error=True) func_builder.add_fn('__delattr__', ('self', 'name'), (f' if {condition}:', ' raise FrozenInstanceError(f"cannot delete field {name!r}")', f' super(cls, self).__delattr__(name)'), locals=locals, overwrite_error=True) I believe this can be fixed by using a weak reference to the class. In such a case I can make PR.
I investigated this further and realized that there is a whole function that updates closures for slotted class.
Lines 1283 to 1301 in c461aa9
def _update_func_cell_for__class__(f, oldcls, newcls): # Returns True if we update a cell, else False. if f is None: # f will be None in the case of a property where not all of # fget, fset, and fdel are used. Nothing to do in that case. return False try: idx = f.__code__.co_freevars.index("__class__") except ValueError: # This function doesn't reference __class__, so nothing to do. return False # Fix the cell to point to the new class, if it's already pointing # at the old class. I'm not convinced that the "is oldcls" test # is needed, but other than performance can't hurt. closure = f.__closure__[idx] if closure.cell_contents is oldcls: closure.cell_contents = newcls return True return False But it doesn't do this for
__setattr__and__delattr__because they reference class viaclsnot via__class__.__delattr__and__setattr__reference the class through closure; hence, making 2 circular referencesI was wrong in my last comment. The two references that make the old class uncollectable are the references from
__delattr__and__setattr__of the new class because closure cells for those are never updated!Given that the solution should be simple: just use
__class__instead ofclsin__delattr__and__setattr__closure and those cells would be updated via_update_func_cell_for__class__. Local tests showed this change solves the leak.I had Codex write test cases for all the variants discussed in this issue:
import gc import unittest from dataclasses import dataclass, fields def collect_named(name): gc.collect() gc.collect() return [ o for o in gc.get_objects() if isinstance(o, type) and o.__name__ == name ] class Issue135228Tests(unittest.TestCase): def test_original_int_annotation_case(self): @dataclass(slots=True) class Issue135228Original: value: int self.assertEqual(collect_named("Issue135228Original"), [Issue135228Original]) def test_subclasses_after_collect(self): class Issue135228Base: pass @dataclass(slots=True) class Issue135228Subclass(Issue135228Base): value: int gc.collect() gc.collect() self.assertEqual(Issue135228Base.__subclasses__(), [Issue135228Subclass]) def test_forward_ref_field_does_not_keep_original_class(self): @dataclass(slots=True) class Issue135228ForwardRef: value: undefined self.assertEqual(collect_named("Issue135228ForwardRef"), [Issue135228ForwardRef]) def test_forward_ref_field_owner_is_reslotted_class(self): class Issue135228OldForwardRef: value: undefined Issue135228NewForwardRef = dataclass(Issue135228OldForwardRef, slots=True) for field in fields(Issue135228NewForwardRef): self.assertIs(field.type.__owner__, Issue135228NewForwardRef) def test_frozen_slots_does_not_keep_original_class(self): @dataclass(frozen=True, slots=True) class Issue135228Frozen: value: int self.assertEqual(collect_named("Issue135228Frozen"), [Issue135228Frozen]) if __name__ == "__main__": unittest.main()
All cases pass on the current tip of main and on the 3.14 branch, so I'm going to say the issue is fixed. If there are any more similar edge cases, probably better to open a new issue.
- moved this from Todo to Done in Release and Deferred blockers 🚫
on Apr 24, 2026 - added a commit that references this issue
on Apr 26, 2026 - added 4 commits that reference this issue
on Jun 20, 2026 Just out of curiosity, since we're now delving into C-land, would it be possible to have a C function that would rework a class in-place to make it a slotted class? I feel like it would solve a number of issues at the root.
yes that is probably a better long-term solution. My understanding is that it's possible in C to change "slottedness" after the fact, but it's safe only if no instances of the class have been created yet, and proving that condition is not trivial. Maybe there's a way but it's not something we can do for 3.14 at this point.
The approach of replacing a class with another class indeed seems flawed.
However, if class building were done properly through a metaclass rather than a decorator, this would not be a problem in the first place, since a metaclass lets you hook into the class creation process itself.
Asking for new means to change slottedness after a class has been created sounds like trying to work around the consequences of an unfortunate API design choice.
On the other hand, given that dataclasses are now the way they are, it might be the best way forward...
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Bug report
Bug description:
While trying to test cattrs on 3.14 I ran into this issue. Here's a simple reproducer that passes on 3.13, but doesn't on 3.14.
Originally I ran into this issue with slotted attrs classes but the core problem is the same. Since slotness cannot be added to a class, dataclasses and attrs classes create a class copy with slots instead. The old class used to hang around until a GC collection happened (guess there are some reference cycles, but I never investigated thoroughly).
On 3.14, a full GC collection does not clean up the original class, causing a leak. Apart from leaking, in case of subclassing the original class will remain in the list of the parent
.__subclasses__(), causing an issue.CPython versions tested on:
3.14
Operating systems tested on:
macOS
Linked PRs
__setattr__and__delattr__in frozen dataclasses with slots #144021