Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
201 changes: 92 additions & 109 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,28 +608,13 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.child.bind(field_name='', parent=self)

def get_initial(self):
if hasattr(self, 'initial_data'):
return self.to_representation(self.initial_data)
return []

def get_value(self, dictionary):
"""
Given the input dictionary, return the field value.
"""
# We override the default field access in order to support
# lists in HTML forms.
if html.is_html_input(dictionary):
return html.parse_html_list(dictionary, prefix=self.field_name, default=empty)
return dictionary.get(self.field_name, empty)

def run_validation(self, data=empty):
"""
We override the default `run_validation`, because the validation
performed by validators and the `.validate()` method should
be coerced into an error dictionary with a 'non_fields_error' key.
"""
(is_empty_value, data) = self.validate_empty_values(data)
is_empty_value, data = self.validate_empty_values(data)
if is_empty_value:
return data

Expand All @@ -644,72 +629,101 @@ def run_validation(self, data=empty):
return value

def run_child_validation(self, data):
"""
Run validation on child serializer.
You may need to override this method to support multiple updates. For example:
child = copy.deepcopy(self.child)
if getattr(self, 'partial', False) or getattr(self.root, 'partial', False):
child.partial = True

# Field.__deepcopy__ re-instantiates the field, wiping any state.
# If the subclass set an instance or initial_data on self.child,
# we manually restore them to the deepcopied child.
child_instance = getattr(self.child, 'instance', None)
if child_instance is not None and child_instance is not self.instance:
child.instance = child_instance
elif hasattr(self, '_instance_map') and isinstance(data, dict):
# Automated instance matching (#8926)
data_pk = data.get('id')
if data_pk is None:
data_pk = data.get('pk')
if data_pk is not None:
child.instance = self._instance_map.get(str(data_pk))
else:
child.instance = None
else:
child.instance = None

self.child.instance = self.instance.get(pk=data['id'])
self.child.initial_data = data
return super().run_child_validation(data)
"""
return self.child.run_validation(data)
child_initial_data = getattr(self.child, 'initial_data', empty)
if child_initial_data is not empty:
child.initial_data = child_initial_data
else:
# Set initial_data for item-level validation if not already set.
child.initial_data = data

validated = child.run_validation(data)
return validated

def to_internal_value(self, data):
"""
List of dicts of native values <- List of dicts of primitive datatypes.
"""
if html.is_html_input(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: [
self.error_messages['not_a_list'].format(input_type=type(data).__name__)
]
})

if not self.allow_empty and len(data) == 0:
message = self.error_messages['empty']
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='empty')
api_settings.NON_FIELD_ERRORS_KEY: [ErrorDetail(self.error_messages['empty'], code='empty')]
})

if self.max_length is not None and len(data) > self.max_length:
message = self.error_messages['max_length'].format(max_length=self.max_length)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='max_length')
api_settings.NON_FIELD_ERRORS_KEY: [ErrorDetail(self.error_messages['max_length'].format(max_length=self.max_length), code='max_length')]
})

if self.min_length is not None and len(data) < self.min_length:
message = self.error_messages['min_length'].format(min_length=self.min_length)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='min_length')
api_settings.NON_FIELD_ERRORS_KEY: [ErrorDetail(self.error_messages['min_length'].format(min_length=self.min_length), code='min_length')]
})

ret = []
errors = []
# Build a primary key mapping for instance updates (#8926)
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 hasattr(self.instance, '__iter__'):
for obj in self.instance:
pk = getattr(obj, 'pk', getattr(obj, 'id', None))
if pk is not None:
instance_map[str(pk)] = obj
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 mapping logic checks hasattr(self.instance, '__iter__') to handle iterables. However, strings are also iterable in Python, which could cause unexpected behavior if a string is accidentally passed as instance. Consider adding an additional check to ensure self.instance is not a string, or explicitly check for list/tuple/QuerySet types.

Copilot uses AI. Check for mistakes.

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({})
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:
ret = []
errors = []

return ret
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:
delattr(self, '_instance_map')

def to_representation(self, data):
"""
List of object instances -> List of dicts of primitive datatypes.
"""
# Dealing with nested relationships, data can be a Manager,
# so, first get a queryset from the Manager if needed
# so, first get a queryset from the Manager if needed.
# We avoid .all() on QuerySets to preserve Issue #2704 behavior.
iterable = data.all() if isinstance(data, models.manager.BaseManager) else data

return [
Expand All @@ -719,62 +733,32 @@ def to_representation(self, data):
def validate(self, attrs):
return attrs

def create(self, validated_data):
return [self.child.create(item) for item in validated_data]

def update(self, instance, validated_data):
raise NotImplementedError(
"Serializers with many=True do not support multiple update by "
"default, only multiple create. For updates it is unclear how to "
"deal with insertions and deletions. If you need to support "
"multiple update, use a `ListSerializer` class and override "
"`.update()` so you can specify the behavior exactly."
"ListSerializer does not support multiple updates by default. "
"Override `.update()` if needed."
)

def create(self, validated_data):
return [
self.child.create(attrs) for attrs in validated_data
]

def save(self, **kwargs):
"""
Save and return a list of object instances.
"""
# 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. "
"If you need to access data before committing to the database then "
"inspect 'serializer.validated_data' instead. "
"You can also pass additional keyword arguments to 'save()' if you "
"need to set extra attributes on the saved model instance. "
"For example: 'serializer.save(owner=request.user)'.'"
)

validated_data = [
{**attrs, **kwargs} for attrs in self.validated_data
]
assert hasattr(self, 'validated_data'), "Call `.is_valid()` before `.save()`."
validated_data = [{**item, **kwargs} for item in self.validated_data]

if self.instance is not None:
self.instance = self.update(self.instance, validated_data)
Comment on lines 818 to 819
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 assertion assert self.instance is not None, ('update() did not return an object instance.') was removed from the save method. This removes an important safety check that ensures update() returns a valid instance. Without this check, if update() returns None, self.instance will be set to None without any warning, which could lead to silent failures. Consider keeping this assertion for defensive programming.

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.

please check this as well @zainnadeem786

assert self.instance is not None, (
'`update()` did not return an object instance.'
)
else:
self.instance = self.create(validated_data)
assert self.instance is not None, (
'`create()` did not return an object instance.'
)

return self.instance
Comment on lines 789 to 829
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 ListSerializer.save() method overrides BaseSerializer.save() but removes several important assertions that exist in the parent class: the 'commit' keyword check, the check preventing save() after accessing data, and the checks for errors. This means users could potentially call save(commit=False) on a ListSerializer or save after accessing .data without getting the helpful error messages from BaseSerializer. Consider re-adding these checks or calling super().save() if possible to maintain consistency with the parent class behavior.

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.

did you check this?


def is_valid(self, *, raise_exception=False):
# This implementation is the same as the default,
# except that we use lists, rather than dicts, as the empty case.
assert hasattr(self, 'initial_data'), (
'Cannot call `.is_valid()` as no `data=` keyword argument was '
'passed when instantiating the serializer instance.'
)
assert hasattr(self, 'initial_data'), "You must pass `data=` to the serializer."

if not hasattr(self, '_validated_data'):
try:
self._validated_data = self.run_validation(self.initial_data)
raw_validated = self.run_validation(self.initial_data)
self._validated_data = raw_validated
except ValidationError as exc:
self._validated_data = []
self._errors = exc.detail
Expand All @@ -786,11 +770,12 @@ def is_valid(self, *, raise_exception=False):

return not bool(self._errors)

def __repr__(self):
return representation.list_repr(self, indent=1)

# Include a backlink to the serializer class on return objects.
# Allows renderers such as HTMLFormRenderer to get the full field info.
@property
def validated_data(self):
if not hasattr(self, '_validated_data'):
msg = 'You must call `.is_valid()` before accessing `.validated_data`.'
raise AssertionError(msg)
return self._validated_data

@property
def data(self):
Expand All @@ -799,20 +784,18 @@ def data(self):

@property
def errors(self):
ret = super().errors
if isinstance(ret, list) and len(ret) == 1 and getattr(ret[0], 'code', None) == 'null':
# Edge case. Provide a more descriptive error than
# "this field may not be null", when no data is passed.
detail = ErrorDetail('No data provided', code='null')
ret = {api_settings.NON_FIELD_ERRORS_KEY: [detail]}
ret = getattr(self, '_errors', [])
if isinstance(ret, dict):
return ReturnDict(ret, serializer=self)
return ReturnList(ret, serializer=self)

def __repr__(self):
return f'<ListSerializer child={self.child}>'

# ModelSerializer & HyperlinkedModelSerializer
# --------------------------------------------


def raise_errors_on_nested_writes(method_name, serializer, validated_data):
"""
Give explicit errors when users attempt to pass writable nested data.
Expand Down
Loading