Skip to content

3.14 regression: slot dataclasses classes leak original class #135228

Description

@Tinche

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.

import gc
from dataclasses import dataclass


@dataclass(slots=True)
class B:
    b: int


gc.collect()

assert [
    o for o in gc.get_objects() if hasattr(o, "__name__") and o.__name__ == "B"
] == [B]

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

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    on Jun 6, 2025
  2. JelleZijlstra commented on Jun 7, 2025

    @JelleZijlstra
    Member

    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__ and B.__weakref__ that refer back to the original class. Not too sure how to fix that yet.

  3. self-assigned this
    on Jun 7, 2025
  4. added a commit that references this issue on Jun 7, 2025
  5. JelleZijlstra commented on Jun 7, 2025

    @JelleZijlstra
    Member

    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.

  6. added
    stdlibStandard Library Python modules in the Lib/ directory
    on Jun 7, 2025
  7. Tinche commented on Jun 28, 2025

    @Tinche
    ContributorAuthor

    @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?

  8. gpshead commented on Jul 20, 2025

    @gpshead
    Member

    release blocker status just to help force consideration. It may wind up deferred.

  9. added a commit that references this issue on Jul 20, 2025
  10. 54 remaining items

  11. added 3 commits that reference this issue on Sep 9, 2025
  12. Prometheus3375 commented on Jan 15, 2026

    @Prometheus3375
    Contributor

    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=True in dataclass call.

    1. frozen enables calling function_frozen_get_del_attr.
    2. 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.
    3. These locals are passed to function __create_fn__ that creates other functions for the class.
    4. __delattr__ and __setattr__ reference the class through closure; hence, making 2 circular references.

    cpython/Lib/dataclasses.py

    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.

  13. Prometheus3375 commented on Jan 19, 2026

    @Prometheus3375
    Contributor

    I investigated this further and realized that there is a whole function that updates closures for slotted class.

    cpython/Lib/dataclasses.py

    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 via cls not via __class__.

    __delattr__ and __setattr__ reference the class through closure; hence, making 2 circular references

    I 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 of cls in __delattr__ and __setattr__ closure and those cells would be updated via _update_func_cell_for__class__. Local tests showed this change solves the leak.

  14. added a commit that references this issue on Apr 12, 2026
  15. JelleZijlstra commented on Apr 24, 2026

    @JelleZijlstra
    Member

    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.

  16. added a commit that references this issue on Apr 26, 2026
  17. mara004 commented on Aug 13, 2026

    @mara004
    Contributor

    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...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

3.14bugs and security fixes3.15pre-release feature fixes, bugs and security fixesstdlibStandard Library Python modules in the Lib/ directorytopic-dataclassestype-bugAn unexpected behavior, bug, or error

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions