Serializer validation: field-level and object-level
ModelSerializer validates field types and model-level constraints (max_length, required) automatically — nothing so far checks anything more specific, like "a due date can't be in the past." That's what custom validation methods are for.
Field-level validation
# tasks/serializers.py
from datetime import date
from rest_framework import serializers
from .models import Task
class TaskSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = ["id", "title", "description", "completed", "due_date", "owner", "created_at"]
read_only_fields = ["owner", "created_at"]
def validate_due_date(self, value):
if value and value < date.today():
raise serializers.ValidationError("Due date can't be in the past.")
return valuevalidate_<field_name> — the method name has to match the field exactly — runs after that field's own type validation passes, and only for that one field. It receives the already-type-validated value and must return it (or a transformed version of it); raising serializers.ValidationError inside it attaches the error specifically to due_date in the response, not to the request as a whole.
Object-level validation
def validate(self, data):
if data.get("completed") and not data.get("title", "").strip():
raise serializers.ValidationError("A completed task needs a title.")
return dataA plain validate() method (no field name) runs after every individual field has already passed its own validation, and receives the full set of validated data at once — the right place for a rule that depends on more than one field together, which no single validate_<field> could express on its own.
What the client sees
{"due_date": ["Due date can't be in the past."]}{"non_field_errors": ["A completed task needs a title."]}A validate_<field> error keys itself to that field automatically — exactly the shape a frontend needs to show an error next to the right input. A validate() error, with no single field to attach to, lands under non_field_errors by default — worth knowing the name ahead of time, since it's easy to assume a cross-field error would show up somewhere else the first time you see it.
Putting multi-field logic inside a validate_<field> method by reaching into self.initial_data for other fields. It technically works, but initial_data is the raw, unvalidated input — reading another field from it bypasses whatever validation that field's own validate_<field> method would have applied. Cross-field logic belongs in validate(), which only ever runs after every individual field already passed.
Next: relationships — Task has an owner, but so far it only ever shows as a bare ID. Nested serializers show related data properly.