skip to content

Input Validation Hooks

is_valid() runs field validators, validate_<field> methods and a cross-field validate(), then save() calls create() or update(). Interviewers probe PATCH with partial=True and uniqueness races.

part ofDjango REST Frameworkoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In Django REST Framework, what does serializer.is_valid() do, and what changes when you call it with raise_exception=True?

level: juniorimportance: must knowfreq 72%

answer

  1. a boolean and two properties
  2. errors versus validated_data
  3. who builds the 400 response
  4. guards before save()

basics

~20 s

is_valid() runs the whole validation pipeline on the data passed as data=, stores either validated_data or errors, and returns a boolean. With raise_exception=True an invalid payload raises serializers.ValidationError, which DRF's exception handler turns into a 400 response carrying the errors.

solid answer

~40 s

`is_valid()` requires the serializer to have been built with `data=`; it runs field conversion, field validators, `validate_<field>()` methods, serializer-level validators and `validate()`, then stores the result: `validated_data` on success, `errors` (a dict keyed by field, plus `non_field_errors`) on failure, and returns `True` or `False`. The result is cached, so a second call does not validate again. Reading `errors` or `validated_data`, or calling `save()`, before `is_valid()` fails an assertion. Without `raise_exception` you must return `Response(serializer.errors, status=400)` yourself; with `raise_exception=True` an invalid payload raises `serializers.ValidationError(serializer.errors)`, and DRF's default exception handler turns it into a 400 response with the errors as the body. The generic create and update views use the second form.

code

python · 13 lines
python
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

from bookings.serializers import BookingSerializer


class BookingCreateView(APIView):
    def post(self, request):
        serializer = BookingSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)  # invalid -> ValidationError -> 400
        serializer.save(created_by=request.user)
        return Response(serializer.data, status=status.HTTP_201_CREATED)

go deeper

for a junior

Remember the sequence: build with data=, call is_valid(), then read validated_data or errors, then save(); raise_exception=True turns failures into a 400 for you.

for a middle

Explain what the pipeline runs, why results are cached, which assertions guard errors, validated_data and save(), and how ValidationError becomes a 400.

for a senior

Spot non-ValidationError exceptions that turn bad input into 500s, and know that model clean() is not part of DRF validation.

for a principal

Decide where validation rules live across serializers, models and database constraints so that every write path enforces the same guarantees.

## What is_valid() runs A Django REST Framework serializer built with `data=request.data` holds raw input in `initial_data`. Calling `is_valid()` runs the whole pipeline once: 1. each writable field converts its value (`to_internal_value()`), applies its own validators and then the serializer's `validate_<field_name>()` method if one exists; 2. if any field failed, validation stops there with every field error collected; 3. serializer-level validators run, such as the uniqueness validators a `ModelSerializer` adds; 4. `validate(attrs)` runs for cross-field rules. For a booking request, that means `check_in` and `check_out` are parsed as dates, `guests` as an integer, then a rule such as "check-out after check-in" runs in `validate()`. ## What it leaves behind | After `is_valid()` returns | `validated_data` | `errors` | return value | |---|---|---|---| | valid input | dict of converted values | `{}` | `True` | | invalid input | `{}` | dict of field names (and `non_field_errors`) to lists of messages | `False` | - The result is **cached**: calling `is_valid()` again on the same serializer does not re-run validation. - Calling it on a serializer created **without `data=`** fails an assertion: `Cannot call .is_valid() as no data= keyword argument was passed`. - Reading **`errors`** or **`validated_data`** before `is_valid()` fails an assertion telling you to call it first. - **`save()`** asserts that `is_valid()` was called and that there are no errors. ## raise_exception=True Without the flag, the view has to branch: - `if serializer.is_valid(): ... else: return Response(serializer.errors, status=400)`. With `raise_exception=True`, an invalid payload raises **`serializers.ValidationError`** carrying `serializer.errors`. `ValidationError` is an `APIException` with status **400**, and DRF's default exception handler turns any `APIException` into a `Response` with that status and the detail as its body. The view code reads as a straight line: validate, save, respond. The generic views rely on it: `CreateModelMixin.create()` and `UpdateModelMixin.update()` call `serializer.is_valid(raise_exception=True)` before `perform_create()` or `perform_update()`. The exact JSON shape of that 400 body, and how a project customises it, is a separate subject. ## Manual branch versus raise_exception | Style | View code | Error response built by | Suits | |---|---|---|---| | manual | `if not serializer.is_valid(): return Response(serializer.errors, status=400)` | the view | endpoints that must do extra work on failure | | `raise_exception=True` | `serializer.is_valid(raise_exception=True)` | DRF's exception handler | nearly everything else | The manual style remains useful when the view must act on failure beyond responding, such as recording a rejected booking attempt. Formatting concerns belong in a custom exception handler instead, which keeps every view uniform. ## A worked booking example Given `{"room": 7, "check_in": "2026-10-03", "check_out": "2026-10-01", "guests": "two"}`: 1. `room` resolves to a `Room` instance; `check_in` and `check_out` parse as dates. 2. `guests` fails: the integer field reports `A valid integer is required.` 3. Because a field failed, `validate()` does not run, so the reversed dates are not reported yet. 4. `is_valid()` returns `False` and `errors` is `{"guests": ["A valid integer is required."]}`; with `raise_exception=True` that dict becomes the 400 body. When the client fixes `guests`, the next request reaches `validate()`, which now rejects the reversed dates. Clients can therefore meet errors in two rounds, which is worth stating in API documentation. ## Things that surprise people - Only **`ValidationError`** is converted into errors. Any other exception raised inside a validation hook, a `KeyError` from a missing dict key for instance, escapes `is_valid()` and becomes a server error. - A Django `django.core.exceptions.ValidationError` raised inside `validate_<field>()` or `validate()` is converted too, so model-layer helpers can be reused; raised in a view, outside the serializer, it is not handled by DRF and becomes a 500. - `is_valid()` does **not** call the model's `full_clean()` or `clean()`; DRF validates on the serializer, and model-level rules must be enforced there or in the database. - `raise_exception` is **keyword-only**: `is_valid(True)` raises a `TypeError` instead of enabling the flag. - A serializer built only with an instance, and no `data=`, has nothing to validate; it exists for output, and calling `is_valid()` on it fails the assertion described above. - Reading `serializer.data` on an invalid serializer returns the submitted values for the writable fields, not a representation of any object.

  • Why does a KeyError inside a DRF validate() method produce a 500 instead of a 400?
    `is_valid()` only catches `ValidationError` (and converts Django's `ValidationError` raised inside the hooks). A `KeyError`, for example from `attrs["check_in"]` on a PATCH that did not send it, escapes validation and the view, and DRF's default exception handler returns `None` for non-API exceptions, so Django serves a 500. Use `attrs.get()` and raise `ValidationError` for expected failures.
  • Does DRF's is_valid() call the model's full_clean()?
    No. Since DRF 3.0 serializers validate on their own and never call `full_clean()` or `clean()`. Rules that live in `Model.clean()` are skipped unless you call them from `validate()` or duplicate them there; rules that must always hold belong in database constraints as well.

saying these in an interview costs you the question

  • You can read serializer.errors without calling is_valid() first; it is just empty.
  • is_valid(raise_exception=True) returns a 400 Response object to the view.
  • Any exception raised inside validate() is turned into a 400 error.
  • is_valid() calls the model's full_clean() like a ModelForm does.
  • Calling is_valid() twice re-runs validation with fresh data.
open as a page

In Django REST Framework, when do you use validate_<field_name>() versus validate(), and in what order does is_valid() run them?

level: middleimportance: must knowfreq 66%

basics

~20 s

validate_<field_name>(value) checks one field after its own conversion and validators; validate(attrs) checks rules across fields after every field passed and serializer-level validators ran. Both return the value, and a ValidationError in validate() lands under non_field_errors unless given a dict.

open as a page

In Django REST Framework, how does serializer.save() decide between create() and update(), and what do keyword arguments passed to save() do?

level: juniorimportance: should knowfreq 55%

basics

~10 s

save() calls update(instance, validated_data) when the serializer was built with an instance, and create(validated_data) otherwise. Keyword arguments are merged into validated_data after validation, which is how server-side values such as created_by=request.user reach the model.

open as a page

In Django REST Framework, what does partial=True change during validation, and why can a PATCH request break cross-field checks in validate()?

level: middleimportance: should knowfreq 52%

basics

~20 s

partial=True makes every field that is absent from the request skip validation entirely: no required error, no default, no validate_<field>(). validate() therefore receives only the sent fields, so cross-field rules must fill the rest from self.instance.

open as a page

Why can two concurrent requests both pass a Django REST Framework UniqueTogetherValidator and still collide, and how do you make the uniqueness guarantee hold?

level: seniorimportance: should knowfreq 46%

basics

~20 s

UniqueValidator and UniqueTogetherValidator only query whether a matching row exists during validation; nothing is locked before the insert. The database unique constraint is the real guarantee, and create() should turn its IntegrityError into a ValidationError instead of a 500.

open as a page