Summary
With DRF 3.18, a validation error raised on the first item (index 0) of a many=True / ListSerializer payload loses its top-level 0. index prefix in the formatted attr. Errors on items at index 1 and above are unaffected, and nested list indices (e.g. line_items.0.account) are also unaffected — only the top-level index 0 is dropped.
This makes it impossible for API clients to know which row of a bulk request a validation error belongs to when the error is on the first row.
Environment
drf-standardized-errors==0.16.0
djangorestframework==3.18.0
Reproduction
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth", "rest_framework"],
DATABASES={},
USE_TZ=True,
)
django.setup()
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from drf_standardized_errors.formatter import ExceptionFormatter
class ItemSerializer(serializers.Serializer):
email = serializers.EmailField()
def formatted_attrs(payload):
serializer = ItemSerializer(data=payload, many=True)
try:
serializer.is_valid(raise_exception=True)
except ValidationError as exc:
errors = ExceptionFormatter(exc, None, None).get_errors()
return [e.attr for e in errors]
# Error on the FIRST item (index 0)
print("error on index 0:", formatted_attrs([{"email": "not-an-email"}, {"email": "a@b.com"}]))
# Error on the SECOND item (index 1)
print("error on index 1:", formatted_attrs([{"email": "a@b.com"}, {"email": "not-an-email"}]))
Actual output (DRF 3.18)
error on index 0: ['email']
error on index 1: ['1.email']
Expected output
error on index 0: ['0.email']
error on index 1: ['1.email']
Root cause
DRF 3.18 changed ListSerializer.to_internal_value to collect child errors in an index-keyed dict instead of a positional list (serializers.py):
# DRF 3.17.x — errors is a positional list
errors = []
for item in data:
try:
...
except ValidationError as exc:
errors.append(exc.detail) # -> [{'email': [...]}, {}]
else:
errors.append({})
if any(errors):
raise ValidationError(errors)
# DRF 3.18.0 — errors is an index-keyed dict
errors = {}
for index, item in enumerate(data):
try:
...
except ValidationError as exc:
errors[index] = exc.detail # -> {0: {'email': [...]}}
if errors:
raise ValidationError(errors)
So the exception detail is now {0: {'email': [ErrorDetail(...)]}} instead of [{'email': [...]}, {}].
In flatten_errors (formatter.py), the dict branch only prefixes the child key when the parent attr is truthy:
elif isinstance(detail, dict):
for key, value in detail.items():
if attr: # <-- formatter.py:138
key = f"{attr}{package_settings.NESTED_FIELD_SEPARATOR}{key}"
fifo.append((value, key, None))
When the top-level dict key is the integer 0, it is passed down as attr, and if attr: evaluates if 0: → False, so the child (email) is never prefixed with 0.. Integer keys 1, 2, … are truthy, so those rows are prefixed correctly — which is why only index 0 is affected.
Suggested fix
Guard against None explicitly rather than relying on truthiness, so the integer 0 is treated as a valid attr segment:
elif isinstance(detail, dict):
for key, value in detail.items():
if attr is not None:
key = f"{attr}{package_settings.NESTED_FIELD_SEPARATOR}{key}"
fifo.append((value, key, None))
(The same if attr: truthiness check appears at formatter.py:126 in the list branch; worth reviewing for consistency, though that path builds attr from str(index) so it is not hit by an integer 0.)
Summary
With DRF 3.18, a validation error raised on the first item (index
0) of amany=True/ListSerializerpayload loses its top-level0.index prefix in the formattedattr. Errors on items at index1and above are unaffected, and nested list indices (e.g.line_items.0.account) are also unaffected — only the top-level index0is dropped.This makes it impossible for API clients to know which row of a bulk request a validation error belongs to when the error is on the first row.
Environment
drf-standardized-errors==0.16.0djangorestframework==3.18.0Reproduction
Actual output (DRF 3.18)
Expected output
Root cause
DRF 3.18 changed
ListSerializer.to_internal_valueto collect child errors in an index-keyed dict instead of a positional list (serializers.py):So the exception detail is now
{0: {'email': [ErrorDetail(...)]}}instead of[{'email': [...]}, {}].In
flatten_errors(formatter.py), the dict branch only prefixes the child key when the parentattris truthy:When the top-level dict key is the integer
0, it is passed down asattr, andif attr:evaluatesif 0:→False, so the child (email) is never prefixed with0.. Integer keys1,2, … are truthy, so those rows are prefixed correctly — which is why only index0is affected.Suggested fix
Guard against
Noneexplicitly rather than relying on truthiness, so the integer0is treated as a valid attr segment:(The same
if attr:truthiness check appears atformatter.py:126in the list branch; worth reviewing for consistency, though that path buildsattrfromstr(index)so it is not hit by an integer0.)