GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — 3.14 regression: slot dataclasses classes leak original class · Issue #135228 · python/cpython

3.14 regression: slot dataclasses classes leak original class #135228

New issue
Copy link
New issue
Copy link
Closed
Closed
3.14 regression: slot dataclasses classes leak original class#135228
Copy link
Assignees
JelleZijlstra
Labels
3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixesstdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytopic-dataclassestype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

Description

@Tinche
Tinche
opened on Jun 6, 2025
Issue body actions

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

  • gh-135228: Break reference cycle between class and descriptors #135230
  • gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible #136893
  • [3.14] gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible (GH-136893) #136960
  • gh-135228: Create __dict__ and __weakref__ descriptors for object #136966
  • [3.14] Revert "[3.14] gh-135228: When @dataclass(slots=True) replaces… #137013
  • Revert "gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible (#136893)" #137014
  • gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible (take 2) #137047
  • [3.14] gh-135228: When @dataclass(slots=True) replaces a dataclass, make the original class collectible (take 2) (GH-137047) #137666
  • gh-105936: Properly update closure cells for __setattr__ and __delattr__ in frozen dataclasses with slots #144021
Reacted by Glyph
Reacted by Glyph and NTFSvolume

Activity

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

    @JelleZijlstra
    JelleZijlstra
    on Jun 7, 2025
    Member
    More actions

    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. JelleZijlstra
    self-assigned this
    on Jun 7, 2025
  4. ZeroIntensity
    added
    3.14bugs and security fixesbugs and security fixes
    topic-dataclasses
    3.15bugs and security fixesbugs and security fixes
    on Jun 7, 2025
  5. JelleZijlstra
    added a commit that references this issue on Jun 7, 2025

    pythongh-135228: Break reference cycle between class and descriptors

    8d1037f
  6. JelleZijlstra commented on Jun 7, 2025

    @JelleZijlstra
    JelleZijlstra
    on Jun 7, 2025
    Member
    More actions

    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.

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

    @Tinche
    Tinche
    on Jun 28, 2025
    ContributorAuthor
    More actions

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

  9. gpshead
    added
    release-blocker
    on Jul 20, 2025
  10. ezio-melotti
    added this to Release and Deferred blockers 🚫on Jul 20, 2025
  11. github-project-automation
    moved this to Todo in Release and Deferred blockers 🚫on Jul 20, 2025
  12. gpshead commented on Jul 20, 2025

    @gpshead
    gpshead
    on Jul 20, 2025
    Member
    More actions

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

  13. JelleZijlstra
    added a commit that references this issue on Jul 20, 2025

    pythongh-135228: When @DataClass(slots=True) replaces a dataclass, ma…

    027f629
  14. 54 remaining items

  15. kumaraditya303
    added 3 commits that reference this issue on Sep 9, 2025

    [3.14] pythongh-135228: When @DataClass(slots=True) replaces a datacl…

    5008e85

    [3.14] Revert "[3.14] pythongh-135228: When @DataClass(slots=True) re…

    bba0a52

    [3.14] pythongh-135228: When @DataClass(slots=True) replaces a datacl…

    4bd33e9
  16. Prometheus3375 commented on Jan 15, 2026

    @Prometheus3375
    Prometheus3375
    on Jan 15, 2026
    Contributor
    More actions

    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.

  17. Prometheus3375 commented on Jan 19, 2026

    @Prometheus3375
    Prometheus3375
    on Jan 19, 2026
    Contributor
    More actions

    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.

  18. gpshead
    added a commit that references this issue on Apr 12, 2026

    [3.13] pythongh-105936: Properly update closure cells for `__setattr_…

    aa1e00f
  19. JelleZijlstra commented on Apr 24, 2026

    @JelleZijlstra
    JelleZijlstra
    on Apr 24, 2026
    Member
    More actions

    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.

  20. JelleZijlstra
    closed this as completedon Apr 24, 2026
  21. github-project-automation
    moved this from Todo to Done in Release and Deferred blockers 🚫on Apr 24, 2026
  22. gpshead
    added a commit that references this issue on Apr 26, 2026

    [3.13] gh-105936: Properly update closure cells for __setattr__ and _…

    9651429
  23. MrReasonable
    added 4 commits that reference this issue on Jun 20, 2026

    fix(toolchain): pin Python floor to 3.14.6 for the frozen+slots datac…

    29a88b1

    fix(toolchain): pin Python floor to 3.14.6 for the frozen+slots datac…

    522c5d6

    fix(toolchain): pin Python floor to 3.14.6 for the frozen+slots datac…

    bedbdd2

    fix(toolchain): pin Python floor to 3.14.6 for the frozen+slots datac…

    c2711da
  24. mara004 commented on Aug 13, 2026

    @mara004
    mara004
    on Aug 13, 2026
    Contributor
    More actions

    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

  • @JelleZijlstra
    JelleZijlstra

Labels

3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixesstdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytopic-dataclassestype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

Projects

  • Release and Deferred blockers 🚫
    Status
    Done
    Show more project fields

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.