In Django REST Framework, what does serializer.is_valid() do, and what changes when you call it with raise_exception=True?
answer
- a boolean and two properties
- errors versus validated_data
- who builds the 400 response
- guards before save()
basics
~20 sis_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 linesfrom 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
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.
Explain what the pipeline runs, why results are cached, which assertions guard errors, validated_data and save(), and how ValidationError becomes a 400.
Spot non-ValidationError exceptions that turn bad input into 500s, and know that model clean() is not part of DRF validation.
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.