Skip to content

ListSerializer (many=True): error on first item (index 0) loses its 0. prefix in attr with DRF 3.18 #121

Description

@sshishov

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.)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions