Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f0375ca
Fix #8926: ListSerializer preserves instance for many=True during val…
zainnadeem786 Jan 25, 2026
ac82e50
Update rest_framework/serializers.py
auvipy Feb 24, 2026
07de4b8
Merge branch 'main' into improve-many-true-validation-guidance
auvipy Feb 24, 2026
c402a57
Fix #8926 with minimal ListSerializer instance matching changes
zainnadeem786 Feb 24, 2026
66b8012
Keep virtualenv ignored in .gitignore
zainnadeem786 Feb 24, 2026
90e1a24
Fix Copilot/auvipy review: safe iterable check, restore save() assert…
zainnadeem786 Feb 24, 2026
0acf49a
Refine ListSerializer review follow-ups and cleanup
zainnadeem786 Feb 24, 2026
1484520
Restore serializers docstrings from upstream main
zainnadeem786 Feb 25, 2026
ef7e976
Update rest_framework/serializers.py
auvipy Feb 25, 2026
c665595
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Feb 25, 2026
b61b472
Remove unreachable return in ListSerializer.to_internal_value
zainnadeem786 Feb 25, 2026
22caa96
Update rest_framework/serializers.py
auvipy Feb 25, 2026
de40cb5
Merge branch 'main' into improve-many-true-validation-guidance
browniebroke Feb 26, 2026
5176e44
Merge branch 'main' into improve-many-true-validation-guidance
auvipy Mar 2, 2026
c9665dd
Address review follow-ups in ListSerializer internals
zainnadeem786 Mar 2, 2026
83e9965
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Mar 14, 2026
21417c8
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/env/
MANIFEST
coverage.*

venv/
!.github
!.gitignore
!.pre-commit-config.yaml
121 changes: 80 additions & 41 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,18 +312,6 @@ def __new__(cls, name, bases, attrs):


def as_serializer_error(exc):
"""
Coerce validation exceptions into a standardized serialized error format.

This function normalizes both Django's `ValidationError` and REST
framework's `ValidationError` into a dictionary structure compatible
with serializer `.errors`, ensuring all values are represented as
lists of error details.

The returned structure conforms to the serializer error contract:
- Field-specific errors are returned as '{field-name: [errors]}'
- Non-field errors are returned under the 'NON_FIELD_ERRORS_KEY'
"""
assert isinstance(exc, (ValidationError, DjangoValidationError))

if isinstance(exc, DjangoValidationError):
Expand Down Expand Up @@ -664,7 +652,24 @@ def run_child_validation(self, data):
self.child.initial_data = data
return super().run_child_validation(data)
"""
return self.child.run_validation(data)
if not hasattr(self.child, 'instance'):
return self.child.run_validation(data)

original_instance = self.child.instance
try:
if (
hasattr(self, '_instance_map') and
isinstance(data, Mapping) and
original_instance is self.instance
):
data_pk = data.get('id')
if data_pk is None:
data_pk = data.get('pk')
self.child.instance = self._instance_map.get(str(data_pk)) if data_pk is not None else None

return self.child.run_validation(data)
finally:
self.child.instance = original_instance

def to_internal_value(self, data):
"""
Expand All @@ -674,12 +679,16 @@ def to_internal_value(self, data):
data = html.parse_html_list(data, default=[])

if not isinstance(data, list):
message = self.error_messages['not_a_list'].format(
input_type=type(data).__name__
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='not_a_list')
api_settings.NON_FIELD_ERRORS_KEY: [
ErrorDetail(
self.error_messages['not_a_list'].format(
input_type=type(data).__name__
),
code='not_a_list'
)
]
})

if not self.allow_empty and len(data) == 0:
message = self.error_messages['empty']
Expand All @@ -702,17 +711,39 @@ def to_internal_value(self, data):
ret = []
errors = []

for item in data:
try:
validated = self.run_child_validation(item)
except ValidationError as exc:
errors.append(exc.detail)
else:
ret.append(validated)
errors.append({})
# Build a primary key lookup for instance matching in many=True updates.
instance_map = {}
if self.instance is not None:
if isinstance(self.instance, Mapping):
instance_map = {str(k): v for k, v in self.instance.items()}
elif isinstance(self.instance, (list, tuple, models.query.QuerySet)):
for obj in self.instance:
pk = getattr(obj, 'pk', getattr(obj, 'id', None))
if pk is not None:
key = str(pk)
# If duplicate keys are present, keep the last value,
# matching standard mapping assignment behavior.
instance_map[key] = obj

self._instance_map = instance_map
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _instance_map is stored as an instance attribute during validation. If the same ListSerializer instance is used concurrently (e.g., in multiple threads), this could lead to race conditions where one thread's instance map overwrites another's. While Django REST Framework serializers are typically instantiated per-request, this could be an issue in certain deployment scenarios. Consider documenting that ListSerializer instances should not be shared across threads during validation, or adding thread-safety mechanisms if concurrent usage is expected.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please cross check this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zainnadeem786 and this?


if any(errors):
raise ValidationError(errors)
try:
for item in data:
try:
validated = self.run_child_validation(item)
except ValidationError as exc:
errors.append(exc.detail)
else:
ret.append(validated)
errors.append({})

if any(errors):
raise ValidationError(errors)

return ret
finally:
if hasattr(self, '_instance_map'):
delattr(self, '_instance_map')

return ret

Expand Down Expand Up @@ -749,6 +780,13 @@ def save(self, **kwargs):
"""
Save and return a list of object instances.
"""
assert hasattr(self, '_errors'), (
'You must call `.is_valid()` before calling `.save()`.'
)
assert not self.errors, (
'You cannot call `.save()` on a serializer with invalid data.'
)

# Guard against incorrect use of `serializer.save(commit=False)`
assert 'commit' not in kwargs, (
"'commit' is not a valid keyword argument to the 'save()' method. "
Expand All @@ -758,6 +796,14 @@ def save(self, **kwargs):
"need to set extra attributes on the saved model instance. "
"For example: 'serializer.save(owner=request.user)'.'"
)
assert not hasattr(self, '_data'), (
"You cannot call `.save()` after accessing `serializer.data`."
"If you need to access data before committing to the database then "
"inspect 'serializer.validated_data' instead. "
)
assert hasattr(self, '_validated_data'), (
'You must call `.is_valid()` before calling `.save()`.'
)

validated_data = [
{**attrs, **kwargs} for attrs in self.validated_data
Expand Down Expand Up @@ -827,29 +873,22 @@ def errors(self):

def raise_errors_on_nested_writes(method_name, serializer, validated_data):
"""
Enforce explicit handling of writable nested and dotted-source fields.

This helper raises clear and actionable errors when a serializer attempts
to perform writable nested updates or creates using the default
`ModelSerializer` behavior.
Give explicit errors when users attempt to pass writable nested data.

Writable nested relationships and dotted-source fields are intentionally
unsupported by default due to ambiguous persistence semantics. Developers
must either:
- Override the `.create()` / `.update()` methods explicitly, or
- Mark nested serializers as `read_only=True`
If we don't do this explicitly they'd get a less helpful error when
calling `.save()` on the serializer.

This check is invoked internally by default `ModelSerializer.create()`
and `ModelSerializer.update()` implementations.
We don't *automatically* support these sorts of nested writes because
there are too many ambiguities to define a default behavior.

Eg. Suppose we have a `UserSerializer` with a nested profile. How should
we handle the case of an update, where the `profile` relationship does
not exist? Any of the following might be valid:

* Raise an application error.
* Silently ignore the nested part of the update.
* Automatically create a profile instance.
"""

ModelClass = serializer.Meta.model
model_field_info = model_meta.get_field_info(ModelClass)

Expand Down
29 changes: 29 additions & 0 deletions tests/test_serializer_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,32 @@ def test(self):
queryset = NullableOneToOneSource.objects.all()
serializer = self.serializer(queryset, many=True)
assert serializer.data


def test_many_true_instance_level_validation_uses_matched_instance():
class Obj:
def __init__(self, id, valid):
self.id = id
self.valid = valid

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField()
status = serializers.CharField()

def validate_status(self, value):
if self.instance is None:
raise serializers.ValidationError("Instance not matched")
if not self.instance.valid:
raise serializers.ValidationError("Invalid instance")
return value

objs = [Obj(1, True), Obj(2, False)]
serializer = TestSerializer(
instance=objs,
data=[{"id": 1, "status": "ok"}, {"id": 2, "status": "fail"}],
many=True,
partial=True,
)

assert not serializer.is_valid()
assert serializer.errors == [{}, {'status': ['Invalid instance']}]