Repository: pydantic/pydantic
Pull request: Make sure Pydantic dataclasses with slots and `validate_assignment` can be unpickled


Fixes https://github.com/pydantic/pydantic/issues/11768.

<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary

<!-- Please give a short summary of the changes. -->

## Related issue number

<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

## Checklist

* [ ] The pull request title is a good summary of the changes - it will be used in the changelog
* [ ] Unit tests for the changes exist
* [ ] Tests pass on CI
* [ ] Documentation reflects the changes where applicable
* [ ] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Problem statement:
missing attribute dataclass copy with slots=True & validate_assignment=True
### Initial Checks

- [x] I confirm that I'm using Pydantic V2

### Description

When copying a pydantic v2 dataclass with both `"validate_assignment": True` and `slots=True`, I get `object has no attribute` for an existing field.

### Example Code

```Python
from copy import copy

from pydantic.dataclasses import dataclass


@dataclass(slots=True, config={"validate_assignment": True})
class Sut:
    b1: int

sut = Sut(0)
print(sut.b1)
copy(sut)
```

### Python, Pydantic & OS Version

```Text
pydantic version: 2.11.3
        pydantic-core version: 2.33.1
          pydantic-core build: profile=release pgo=false
                 install path: /home/dani/.virtualenvs/sc-api-DipHMMiG/lib/python3.12/site-packages/pydantic
               python version: 3.12.9 (main, Apr  4 2025, 08:52:04) [GCC 14.2.0]
                     platform: Linux-6.11.0-8-generic-x86_64-with-glibc2.40
             related packages: typing_extensions-4.13.2 mypy-1.15.0
                       commit: unknown
```

Hints from issue discussion:
I'm not sure how to fix this yet. We could define a custom `__copy__()` method on Pydantic dataclasses, but the issue lies in the fact that pickling (see repro below) uses `setattr()`, and pydantic-core excepts field values to be present when validating on assignment. Probably we can override `__setstate__()` but I'll have to check with the stdlib implementation.

Repro with pickling:

```python
import pickle

from pydantic.dataclasses import dataclass


@dataclass(slots=True, config={"validate_assignment": True})
class Sut:
    b1: int

pickle.loads(pickle.dumps(Sut(0)))
# AttributeError
```

Commit message: Make sure Pydantic dataclasses with slots and `validate_assignment` are unpickable

Patch to review:
diff --git a/pydantic/_internal/_dataclasses.py b/pydantic/_internal/_dataclasses.py
index 954ab32f23..656ef78122 100644
--- a/pydantic/_internal/_dataclasses.py
+++ b/pydantic/_internal/_dataclasses.py
@@ -5,7 +5,7 @@
 import dataclasses
 import typing
 import warnings
-from functools import partial, wraps
+from functools import partial
 from typing import Any, ClassVar
 
 from pydantic_core import (
@@ -178,22 +178,12 @@ def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -
     # We are about to set all the remaining required properties expected for this cast;
     # __pydantic_decorators__ and __pydantic_fields__ should already be set
     cls = typing.cast('type[PydanticDataclass]', cls)
-    # debug(schema)
 
     cls.__pydantic_core_schema__ = schema
-    cls.__pydantic_validator__ = validator = create_schema_validator(
+    cls.__pydantic_validator__ = create_schema_validator(
         schema, cls, cls.__module__, cls.__qualname__, 'dataclass', core_config, config_wrapper.plugin_settings
     )
     cls.__pydantic_serializer__ = SchemaSerializer(schema, core_config)
-
-    if config_wrapper.validate_assignment:
-
-        @wraps(cls.__setattr__)
-        def validated_setattr(instance: Any, field: str, value: str, /) -> None:
-            validator.validate_assignment(instance, field, value)
-
-        cls.__setattr__ = validated_setattr.__get__(None, cls)  # type: ignore
-
     cls.__pydantic_complete__ = True
     return True
 
diff --git a/pydantic/dataclasses.py b/pydantic/dataclasses.py
index f1ac051dab..b3353bbec4 100644
--- a/pydantic/dataclasses.py
+++ b/pydantic/dataclasses.py
@@ -3,6 +3,7 @@
 from __future__ import annotations as _annotations
 
 import dataclasses
+import functools
 import sys
 import types
 from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, NoReturn, TypeVar, overload
@@ -264,6 +265,35 @@ def create_dataclass(cls: type[Any]) -> type[PydanticDataclass]:
             **kwargs,
         )
 
+        if config_wrapper.validate_assignment:
+
+            @functools.wraps(cls.__setattr__)
+            def validated_setattr(instance: Any, field: str, value: str, /) -> None:
+                type(instance).__pydantic_validator__.validate_assignment(instance, field, value)
+
+            cls.__setattr__ = validated_setattr.__get__(None, cls)  # type: ignore
+
+            if slots and not hasattr(cls, '__setstate__'):
+                # If slots is set, `pickle` (relied on by `copy.copy()`) will use
+                # `__setattr__()` to reconstruct the dataclass. However, the custom
+                # `__setattr__()` set above relies on `validate_assignment()`, which
+                # in turn excepts all the field values to be already present on the
+                # instance, resulting in attribute errors.
+                # As such, we make use of `object.__setattr__()` instead.
+                # Note that we do so only if `__setstate__()` isn't already set (this is the
+                # case if on top of `slots`, `frozen` is used).
+
+                # Taken from `dataclasses._dataclass_get/setstate()`:
+                def _dataclass_getstate(self: Any) -> list[Any]:
+                    return [getattr(self, f.name) for f in dataclasses.fields(self)]
+
+                def _dataclass_setstate(self: Any, state: list[Any]) -> None:
+                    for field, value in zip(dataclasses.fields(self), state):
+                        object.__setattr__(self, field.name, value)
+
+                cls.__getstate__ = _dataclass_getstate  # pyright: ignore[reportAttributeAccessIssue]
+                cls.__setstate__ = _dataclass_setstate  # pyright: ignore[reportAttributeAccessIssue]
+
         # This is an undocumented attribute to distinguish stdlib/Pydantic dataclasses.
         # It should be set as early as possible:
         cls.__is_pydantic_dataclass__ = True
diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py
index f0a6960f65..a3d695f1c8 100644
--- a/tests/test_dataclasses.py
+++ b/tests/test_dataclasses.py
@@ -2430,6 +2430,23 @@ class Model:
     assert dc.b == 'bar'
 
 
+# Must be defined at the module level to be pickable:
+@pydantic.dataclasses.dataclass(slots=True, config={'validate_assignment': True})
+class DataclassSlotsValidateAssignment:
+    a: int
+
+
+@pytest.mark.skipif(sys.version_info < (3, 10), reason='slots are only supported for dataclasses in Python >= 3.10')
+def test_dataclass_slots_validate_assignment():
+    """https://github.com/pydantic/pydantic/issues/11768"""
+
+    m = DataclassSlotsValidateAssignment(1)
+    m_pickle = pickle.loads(pickle.dumps(m))
+    assert m_pickle.a == 1
+    with pytest.raises(ValidationError):
+        m.a = 'not_an_int'
+
+
 @pytest.mark.parametrize(
     'dataclass_decorator',
     [


Code review comments:
diff_hunk: @@ -2430,6 +2430,23 @@ class Model:
     assert dc.b == 'bar'
 
 
+# Must be defined at the module level to be pickable:
path: tests/test_dataclasses.py
text: @user1:
```suggestion
# Must be defined at the module level to be picklable:
```

diff_hunk: @@ -264,6 +265,35 @@ def create_dataclass(cls: type[Any]) -> type[PydanticDataclass]:
             **kwargs,
         )
 
+        if config_wrapper.validate_assignment:
+
+            @functools.wraps(cls.__setattr__)
+            def validated_setattr(instance: Any, field: str, value: str, /) -> None:
+                type(instance).__pydantic_validator__.validate_assignment(instance, field, value)
+
+            cls.__setattr__ = validated_setattr.__get__(None, cls)  # type: ignore
+
+            if slots and not hasattr(cls, '__setstate__'):
+                # If slots is set, `pickle` (relied on by `copy.copy()`) will use
+                # `__setattr__()` to reconstruct the dataclass. However, the custom
+                # `__setattr__()` set above relies on `validate_assignment()`, which
+                # in turn excepts all the field values to be already present on the
path: pydantic/dataclasses.py
text: @user1:
```suggestion
                # in turn expects all the field values to be already present on the
```