Skip to content

← Data Modelling and Invariants step 25 of 25

Hard Framework

__slots__, part 2: the eight things it breaks

@dataclass(slots=True) cannot modify a class in place — __slots__ has to exist before the class object is created. So the decorator builds a brand-new class object from the old one’s namespace and returns that. Every breakage below is a consequence of that one implementation fact, and every one of them is a hard runtime failure produced by a change that looks cosmetic in review.

The eight things it breaks

  1. Class-attribute defaults become slot descriptors. getattr(cls, "x") no longer returns the default; it returns a member_descriptor. Anything doing “read the class to find the default” silently changes behaviour.
  2. Decorators applied below @dataclass(slots=True) are stale. They ran on the old class object; the name now points at the new one. Same for any registry populated from the class body.
  3. Zero-argument super() raises TypeError — the implicit __class__ cell still points at the original class, which is no longer in the MRO. This is gh-90562, fixed only in 3.14. A class that works on 3.14 raises on 3.13, with no static warning.
  4. functools.cached_property raises — it caches into self.__dict__, and there isn’t one.
  5. No weak references unless you also pass weakref_slot=True (3.11+). WeakSet, WeakValueDictionary and most observer registries stop working.
  6. Two slotted classes cannot be multiply inheritedTypeError: multiple bases have instance lay-out conflict. Note the timing: the diamond is legal today, and becomes illegal the moment the second base gets slots=True.
  7. __init_subclass__ with parameters is a TypeError (gh-91126).
  8. __slots__ has not been the field list since 3.11 — inherited names are excluded, so a subclass’s __slots__ is a subset of its fields. Read fields(), never __slots__.

What it actually saves — and does not

Measured on 3.14.6, one million three-int instances, tracemalloc: 106.5 B/instance with __dict__ vs 74.2 B with slots — about 30%, not 10×. The folk claim is a decade out of date: PEP 412 key-sharing plus 3.11’s lazily created instance namespaces already recovered most of it. Attribute reads are 3.9 ns vs 3.8 ns — no difference at all.

Where slots still wins big is when key sharing breaks: touch obj.__dict__ directly and the dict form jumps to 170.6 B; give instances divergent attribute sets and it goes to 373.8 B. Then slots is 5×. (And sys.getsizeof is the wrong tool here — it reports the object without its dict. Use tracemalloc.)

The real reason to reach for it is static, not dynamic. Under mypy, slots=True makes a typo’d self.totl = 1 an error. On a plain dataclass, mypy accepts any method inventing any new attribute.


Your task

Implement:

def slots_safety_check(
    cls: type[DataclassInstance],
    target_python: tuple[int, int],
    migrating: frozenset[str],
) -> list[str]:

returning the sorted, de-duplicated reasons that adding slots=True to cls would break it. migrating names the classes your migration is about to slot — you need it because breakage 6 depends on what the bases will become, not on what they are today.

Detect exactly these, with exactly these strings:

reason detection
"cached-property" a functools.cached_property in vars(cls)
"zero-arg-super" a method whose __code__.co_freevars contains "__class__"only when target_python < (3, 14)
"instance-dict-access" a method whose __code__.co_names contains "__dict__"
"set-name-descriptor" a value in vars(cls) whose type defines __set_name__ (excluding cached_property, already reported)
"init-subclass-kwargs" an __init_subclass__ taking more than just cls
"multiple-slotted-bases" more than one non-object base that is either in migrating or already has a non-empty __slots__

solve(target, target_python, migrating) adapts the JSON inputs (a two-element list and a list of names) to that signature.

Why co_freevars and co_names? Because super() with no arguments is compiled into a closure over an implicit __class__ cell — the freevar is the only reliable trace of it, and it is also present for a bare __class__ reference. co_names holds the global and attribute names a code object touches, which is how self.__dict__ shows up without parsing source.

Types. Reaching a function through classmethod/staticmethod wrappers needs getattr(value, "__func__", value); narrowing the result with isinstance(inner, FunctionType) is what keeps Any out of the helper’s return type. And note what mypy models here: __slots__ membership, and none of the eight breakages. This is the clearest demonstration in the whole course of where static analysis ends and knowing the runtime begins.