skip to content

In Django REST Framework, what JSON shape does ValidationError produce when raised with a string, a dict, from serializer errors and from a many=True serializer?

level: middleimportance: should knowfreq 44%

answer

  1. detail is always a list or dict
  2. a string becomes a one-item list
  3. non_field_errors inside serializers
  4. 3.18 keys list errors by index
  5. ErrorDetail carries a code

basics

~20 s

DRF's ValidationError never uses the detail wrapper: a string becomes a JSON list, a dict passes through, serializer errors map fields to message lists, and since 3.18 many=True errors are a dict keyed by the index of each invalid item.

solid answer

~40 s

`rest_framework.exceptions.ValidationError` coerces its detail to a list unless it is already a list or dict, and the default handler returns lists and dicts as the whole body. So `raise ValidationError('Job is closed.')` in a view returns `["Job is closed."]`, and a dict such as `{'salary_max': 'Must exceed salary_min.'}` returns as given. `serializer.is_valid(raise_exception=True)` returns `{field: [messages]}` plus `non_field_errors` (the `NON_FIELD_ERRORS_KEY` setting) for errors raised in `validate()`. Since DRF 3.18.0, a `many=True` serializer reports `{"1": {...}}`, keyed by the index of each invalid item, instead of a list with an empty dict per valid item; `LIST_SERIALIZER_ERRORS_AS_DICT = False` restores the old list, deprecated for removal in 3.20. Each message is an `ErrorDetail` with a `.code`, exposed by `get_codes()` and `get_full_details()`.

code

python · 20 lines
python
from rest_framework import serializers


class JobSerializer(serializers.Serializer):
    title = serializers.CharField()
    salary_min = serializers.IntegerField()
    salary_max = serializers.IntegerField()

    def validate(self, attrs):
        if attrs["salary_max"] < attrs["salary_min"]:
            raise serializers.ValidationError("salary_max must be at least salary_min.")
        return attrs


bulk = JobSerializer(data=[
    {"title": "Backend", "salary_min": 1, "salary_max": 2},
    {"salary_min": 1, "salary_max": 2},
], many=True)
bulk.is_valid()
# DRF 3.18: {1: {'title': [ErrorDetail(string='This field is required.', code='required')]}}

go deeper

for a junior

Recognise the field-to-list-of-messages body from serializer errors and the non_field_errors key.

for a middle

Explain why a string raised in a view becomes a bare list, the normalisation inside serializers, and get_codes() versus get_full_details().

for a senior

Handle the 3.18 many=True format change, keep bulk clients working with LIST_SERIALIZER_ERRORS_AS_DICT, and push codes into the error contract.

for a principal

Make error shapes part of the published contract, with codes as the stable interface and upgrades that change shapes treated as versioned changes.

## Why ValidationError is different In Django REST Framework, `ValidationError` (import it as `serializers.ValidationError` to avoid confusion with Django's class of the same name) is the 400 subclass of `APIException`. Two rules produce all of its shapes: 1. its constructor turns the detail into a **list** unless it is already a list or a dict (a tuple becomes a list); 2. the default exception handler returns a list or dict detail **as the whole body**, without the `{"detail": ...}` wrapper other exceptions get. Every message inside is an `ErrorDetail`, a `str` subclass with a `.code` attribute. ## The shapes you will see | Where it comes from | Body | |---|---| | `raise ValidationError('Job is closed.')` in a view | `["Job is closed."]` | | `raise ValidationError({'salary_max': 'Must exceed salary_min.'})` in a view | `{"salary_max": "Must exceed salary_min."}` | | a field fails during `is_valid(raise_exception=True)` | `{"title": ["This field is required."]}` | | a string raised inside `Serializer.validate()` | `{"non_field_errors": ["..."]}` | | a nested serializer field fails | `{"company": {"name": ["This field may not be blank."]}}` | | `many=True`, second of three items invalid (3.18) | `{"1": {"title": ["This field is required."]}}` | Notes on the rows: - inside a serializer, DRF normalises errors so each field maps to a **list** and bare lists go under `NON_FIELD_ERRORS_KEY`, default `'non_field_errors'`; raised directly in a view, no such normalisation happens, which is why the first two rows look different; - Django's own `ValidationError`, raised by model validators during serializer validation, is converted the same way, keeping its codes; - index keys are Python integers in `serializer.errors` and become strings in the JSON body. ## The DRF 3.18 change for lists Before 3.18, a `ListSerializer` (any serializer with `many=True`) reported errors as a list with one entry per input item, `{}` for valid ones: `[{}, {"title": [...]}, {}]`. DRF 3.18.0 changed this to a dict containing only the invalid indexes, a **breaking change** for clients that walked the list. 3.18.1 added the `LIST_SERIALIZER_ERRORS_AS_DICT` setting (default `True`); setting it to `False` restores the list format, emits a deprecation warning and is scheduled for removal in 3.20. A partner API with bulk endpoints should announce this change or pin the old format while clients migrate. ## Codes for machines, messages for humans - `exc.detail`: the messages, in the shape above; - `exc.get_codes()`: the same shape with codes only, e.g. `{"title": ["required"]}`; - `exc.get_full_details()`: each message as `{"message": ..., "code": ...}`. Common codes include `required`, `null`, `blank`, `invalid`, `max_length` and `unique`. Partners should branch on codes; messages are translatable text and can change. ## Why this matters for a partner contract Out of the box one API can return a bare list, a flat dict, a dict of lists, an index-keyed dict and `{"detail": ...}` for non-validation errors. Clients then write shape-sniffing code. The usual cure is a custom `EXCEPTION_HANDLER` that wraps every error in one envelope and uses `get_full_details()` to carry codes. Raising `ValidationError` with a dict keyed by field name, rather than a bare string, also keeps view-level errors field-addressable.

  • How would a client tell 'required' from 'invalid' without parsing the English message?
    Expose codes. Every message is an `ErrorDetail` with `.code`, and `exc.get_full_details()` returns `{"message": ..., "code": ...}` for each one in the same nested shape. A custom exception handler can put that into the body so clients branch on `required`, `invalid` or `unique`.
  • Your bulk-import clients broke after upgrading to DRF 3.18. What changed and how do you buy time?
    `many=True` errors changed from a list with one entry per item to a dict keyed by the invalid items' indexes. Set `LIST_SERIALIZER_ERRORS_AS_DICT` to `False` to restore the list while clients migrate; it warns as deprecated and is planned for removal in 3.20, so treat it as temporary.

saying these in an interview costs you the question

  • raise ValidationError('msg') returns {"detail": "msg"}
  • Field errors are always plain strings, never lists
  • many=True errors are still a list with an empty dict per valid item in 3.18
  • non_field_errors appears whenever a view raises a string ValidationError
  • Error codes are lost once the message is rendered to JSON