From a2dda3959e2ea494260ba1de0b99a53b00e717e4 Mon Sep 17 00:00:00 2001 From: Jakub Stawowy Date: Sat, 29 Aug 2026 20:16:35 +0200 Subject: [PATCH 1/2] Add ListSerializer error format compatibility setting --- docs/api-guide/serializers.md | 4 +- docs/api-guide/settings.md | 10 +++++ rest_framework/__init__.py | 4 ++ rest_framework/serializers.py | 12 ++++++ rest_framework/settings.py | 1 + tests/test_serializer.py | 8 +++- tests/test_serializer_bulk_update.py | 22 +++++++---- tests/test_serializer_lists.py | 55 ++++++++++++++++++++++++---- 8 files changed, 99 insertions(+), 17 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 2ec98f01bf..007b5f7a63 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -159,7 +159,9 @@ When deserializing data, you always need to call `is_valid()` before attempting Each key in the dictionary will be the field name, and the values will be lists of strings of any error messages corresponding to that field. The `non_field_errors` key may also be present, and will list any general validation errors. The name of the `non_field_errors` key may be customized using the `NON_FIELD_ERRORS_KEY` REST framework setting. -When deserializing a list of items, errors will be returned as a list of dictionaries representing each of the deserialized items. +When deserializing a list of items, errors are returned as a list of dictionaries representing each item. This list-based format is deprecated and will be removed in REST framework 3.20. + +To use the dictionary-based format introduced in REST framework 3.18, set `LIST_SERIALIZER_ERRORS_AS_DICT` to `True`. Errors will then be returned as a dictionary keyed by the indexes of invalid items, without entries for valid items. #### Raising an exception on invalid data diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index f0fc5a455a..ca35614721 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -462,6 +462,16 @@ A string representing the key that should be used for serializer errors that do Default: `'non_field_errors'` +#### LIST_SERIALIZER_ERRORS_AS_DICT + +Controls the format of per-item validation errors produced by `ListSerializer`, including serializers instantiated with `many=True`. + +When set to `False`, errors are returned as a list with one entry for each input item and an empty dictionary for each valid item. This format is deprecated and will be removed in REST framework 3.20. + +When set to `True`, errors are returned as a dictionary keyed by the indexes of invalid items. Valid items are omitted from the dictionary. This format was introduced in REST framework 3.18.0 and is planned to become the default again in REST framework 3.19. + +Default: `False` + #### URL_FIELD_NAME A string representing the key that should be used for the URL fields generated by `HyperlinkedModelSerializer`. diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index d9de0a460e..ff4260320a 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -22,3 +22,7 @@ # Default datetime input and output formats ISO_8601 = 'iso-8601' DJANGO_DURATION_FORMAT = 'django' + + +class RemovedInDRF320Warning(PendingDeprecationWarning): + pass diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index a8aa3df68f..4cdff6119d 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -15,6 +15,7 @@ import copy import inspect import traceback +import warnings from collections import defaultdict from collections.abc import Mapping @@ -26,6 +27,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from rest_framework import RemovedInDRF320Warning from rest_framework.compat import postgres_fields from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.fields import get_error_detail @@ -709,6 +711,16 @@ def to_internal_value(self, data): ret.append(validated) if errors: + if not api_settings.LIST_SERIALIZER_ERRORS_AS_DICT: + warnings.warn( + 'The list-based error format for `ListSerializer` is ' + 'deprecated and will be removed in DRF 3.20. Set ' + '`REST_FRAMEWORK["LIST_SERIALIZER_ERRORS_AS_DICT"]` to ' + '`True` to use the dictionary-based error format.', + RemovedInDRF320Warning, + stacklevel=4 + ) + errors = [errors.get(index, {}) for index in range(len(data))] raise ValidationError(errors) return ret diff --git a/rest_framework/settings.py b/rest_framework/settings.py index c6cc97c53d..6cc300ee4b 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -86,6 +86,7 @@ # Exception handling 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler', 'NON_FIELD_ERRORS_KEY': 'non_field_errors', + 'LIST_SERIALIZER_ERRORS_AS_DICT': False, # Testing 'TEST_REQUEST_RENDERER_CLASSES': [ diff --git a/tests/test_serializer.py b/tests/test_serializer.py index fac49dcb94..b5dcb6e185 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -8,7 +8,9 @@ from django.db import models from django.test import TestCase -from rest_framework import exceptions, fields, relations, serializers +from rest_framework import ( + RemovedInDRF320Warning, exceptions, fields, relations, serializers +) from rest_framework.fields import Field from .models import ( @@ -667,7 +669,9 @@ class ExampleSerializer(serializers.Serializer): def test_4606_regression(self): serializer = self.Serializer(data=[{"name": "liz"}], many=True) - with pytest.raises(serializers.ValidationError): + with pytest.warns(RemovedInDRF320Warning), pytest.raises( + serializers.ValidationError + ): serializer.is_valid(raise_exception=True) diff --git a/tests/test_serializer_bulk_update.py b/tests/test_serializer_bulk_update.py index c888e6aa82..3082c61244 100644 --- a/tests/test_serializer_bulk_update.py +++ b/tests/test_serializer_bulk_update.py @@ -3,7 +3,7 @@ """ from django.test import TestCase -from rest_framework import serializers +from rest_framework import RemovedInDRF320Warning, serializers class BulkCreateSerializerTests(TestCase): @@ -65,12 +65,15 @@ def test_bulk_create_errors(self): 'author': 'Haruki Murakami' } ] - expected_errors = { - 2: {'id': ['A valid integer is required.']} - } + expected_errors = [ + {}, + {}, + {'id': ['A valid integer is required.']} + ] serializer = self.BookSerializer(data=data, many=True) - assert serializer.is_valid() is False + with self.assertWarns(RemovedInDRF320Warning): + assert serializer.is_valid() is False assert serializer.errors == expected_errors assert serializer.validated_data == [] @@ -80,11 +83,16 @@ def test_invalid_list_datatype(self): """ data = ['foo', 'bar', 'baz'] serializer = self.BookSerializer(data=data, many=True) - assert serializer.is_valid() is False message = 'Invalid data. Expected a dictionary, but got str.' - expected_errors = {idx: {'non_field_errors': [message]} for idx in range(len(data))} + expected_errors = [ + {'non_field_errors': [message]}, + {'non_field_errors': [message]}, + {'non_field_errors': [message]} + ] + with self.assertWarns(RemovedInDRF320Warning): + assert serializer.is_valid() is False assert serializer.errors == expected_errors def test_invalid_single_datatype(self): diff --git a/tests/test_serializer_lists.py b/tests/test_serializer_lists.py index 0fdfcdb87b..689a0e5f24 100644 --- a/tests/test_serializer_lists.py +++ b/tests/test_serializer_lists.py @@ -1,8 +1,11 @@ +import warnings + import pytest from django.http import QueryDict +from django.test import override_settings from django.utils.datastructures import MultiValueDict -from rest_framework import serializers +from rest_framework import RemovedInDRF320Warning, serializers from rest_framework.exceptions import ErrorDetail from tests.models import ( CustomManagerModel, NullableOneToOneSource, OneToOneTarget @@ -885,9 +888,9 @@ def test(self): assert serializer.data -class TestListSerializerDictErrorBehavior: +class TestListSerializerErrorBehavior: """ - Tests dict-based error structure for ListSerializer, and consistency with ListField. + Tests both ListSerializer error formats and consistency with ListField. https://github.com/encode/django-rest-framework/issues/7279 """ @@ -908,8 +911,31 @@ class WrapperSerializer(serializers.Serializer): self.SampleSerializer = SampleSerializer self.WrapperSerializer = WrapperSerializer - def test_listserializer_dict_error_format(self): + def test_listserializer_list_error_format_by_default(self): + data = [ + {"num": "1"}, + {"num": "x"}, + {"num": "0"}, + {"num": "hello"}, + ] + + serializer = self.SampleSerializer(data=data, many=True) + with pytest.warns( + RemovedInDRF320Warning, + match='LIST_SERIALIZER_ERRORS_AS_DICT' + ) as warning: + assert not serializer.is_valid() + + errors = serializer.errors + assert isinstance(errors, list) + assert errors[0] == {} + assert errors[1] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]} + assert errors[2] == {} + assert errors[3] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]} + assert warning[0].filename == __file__ + @override_settings(REST_FRAMEWORK={'LIST_SERIALIZER_ERRORS_AS_DICT': True}) + def test_listserializer_dict_error_format(self): data = [ {"num": "1"}, {"num": "x"}, @@ -918,15 +944,30 @@ def test_listserializer_dict_error_format(self): ] serializer = self.SampleSerializer(data=data, many=True) - serializer.is_valid() + with warnings.catch_warnings(): + warnings.simplefilter('error', RemovedInDRF320Warning) + assert not serializer.is_valid() errors = serializer.errors assert isinstance(errors, dict) assert set(errors.keys()) == {1, 3} - assert errors[1] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]} assert errors[3] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]} + @override_settings(REST_FRAMEWORK={'LIST_SERIALIZER_ERRORS_AS_DICT': False}) + def test_listserializer_explicit_legacy_error_format(self): + data = [{"num": "wrong"}] + + serializer = self.SampleSerializer(data=data, many=True) + with pytest.warns(RemovedInDRF320Warning): + assert not serializer.is_valid() + + assert isinstance(serializer.errors, list) + assert serializer.errors == [ + {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]} + ] + + @override_settings(REST_FRAMEWORK={'LIST_SERIALIZER_ERRORS_AS_DICT': True}) def test_listserializer_and_listfield_consistency(self): data = { @@ -945,7 +986,7 @@ def test_listserializer_and_listfield_consistency(self): } serializer = self.WrapperSerializer(data=data) - serializer.is_valid() + assert not serializer.is_valid() errors = serializer.errors From c69cbb56ff7fbb5499cb0f94019e34c26f28c461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asif=20Saif=20Uddin=20=7B=22Auvi=22=3A=22=E0=A6=85?= =?UTF-8?q?=E0=A6=AD=E0=A6=BF=22=7D?= Date: Mon, 31 Aug 2026 10:33:08 +0600 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rest_framework/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 4cdff6119d..aa2955a3e9 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -718,7 +718,7 @@ def to_internal_value(self, data): '`REST_FRAMEWORK["LIST_SERIALIZER_ERRORS_AS_DICT"]` to ' '`True` to use the dictionary-based error format.', RemovedInDRF320Warning, - stacklevel=4 + stacklevel=5 ) errors = [errors.get(index, {}) for index in range(len(data))] raise ValidationError(errors)