DRF Validation
Validation in DRF runs in a fixed order. Knowing the order is the difference between “my custom validator never fires” and “I know exactly where to put it.”
Order of operations on serializer.is_valid()
- Per-field
to_internal_value(type coercion:"42"→42, ISO strings → datetimes). A failure here means the field never reaches your validators. - Per-field built-in validators (
max_length,min_value,UniqueValidator, etc.). - Per-field
validators=[...]list passed to the field. - Field-level
validate_<fieldname>(self, value)method on the serializer. - Object-level
validate(self, attrs)method on the serializer. UniqueTogetherValidator/UniqueForDateValidatorfromMeta.validators.
If any step raises serializers.ValidationError, later steps for that field are skipped. Other fields still get checked — DRF collects all field errors and returns them in a single response.
Field-level
class SignupSerializer(serializers.Serializer):
email = serializers.EmailField()
age = serializers.IntegerField()
def validate_age(self, value):
if value < 13:
raise serializers.ValidationError("Must be 13 or older.")
return value # MUST return — silent bug if you forget
The method receives the already-coerced value (step 1 already ran). Always return value — forgetting it makes validated_data get None.
Object-level
For cross-field rules.
def validate(self, attrs):
if attrs["start"] >= attrs["end"]:
raise serializers.ValidationError({"end": "Must be after start."})
return attrs
Use a dict to attach the error to a specific field; a plain string puts it under non_field_errors.
Reusable validator callables
def is_even(value):
if value % 2:
raise serializers.ValidationError("Must be even.")
class ThingSerializer(serializers.Serializer):
n = serializers.IntegerField(validators=[is_even])
Validators can be functions or classes with __call__. Class-based validators that need the serializer can implement set_context(self, serializer_field) (deprecated) or accept it via requires_context = True on __call__.
class UniqueForUser:
requires_context = True
def __call__(self, value, serializer_field):
user = serializer_field.context["request"].user
if Thing.objects.filter(owner=user, name=value).exists():
raise serializers.ValidationError("You already have one with this name.")
ModelSerializer’s hidden validators
When you write class Meta: model = Book; fields = ["title"] and title has unique=True, DRF auto-attaches a UniqueValidator to the serializer field. This costs an extra SELECT ... WHERE title = ? query on every write. It’s correct but surprising — a common cause of “why does this serializer hit the DB twice?”
To remove it (e.g. when you handle uniqueness in a transaction yourself):
class BookSerializer(serializers.ModelSerializer):
title = serializers.CharField(validators=[]) # strip auto validators
class Meta:
model = Book
fields = ["title"]
UniqueTogetherValidator
For composite uniqueness. Lives in Meta.validators:
class MembershipSerializer(serializers.ModelSerializer):
class Meta:
model = Membership
fields = ["user", "team"]
validators = [
UniqueTogetherValidator(
queryset=Membership.objects.all(),
fields=["user", "team"],
)
]
Returning all errors at once
{
"email": ["Enter a valid email address."],
"age": ["Must be 13 or older."],
"non_field_errors": ["End must be after start."]
}
DRF collects errors per field. The default response code is 400. If you want partial success (some fields rejected, others kept), do not use a single serializer — split the operation.
raise_exception=True shortcut
serializer.is_valid(raise_exception=True) # raises rest_framework.exceptions.ValidationError
The DRF exception handler catches it and returns the 400 response automatically. Without raise_exception, you must check if not serializer.is_valid() and return the error response yourself.
Custom error format / error codes
Each ValidationError accepts a code:
raise serializers.ValidationError("Must be even.", code="parity")
Build a custom EXCEPTION_HANDLER (see 14_exception_handling.md) to reshape the error envelope (e.g. {"errors": [{"field": ..., "code": ..., "message": ...}]}).
Validation vs clean() (Django Forms parallel)
DRF doesn’t run Django model .clean() or .full_clean(). If you have validation on the model, you have three options:
- Duplicate the rule in the serializer (most explicit).
- Call
instance.full_clean()inside.create()/.update()and translatedjango.core.exceptions.ValidationErrorintorest_framework.exceptions.ValidationError. - Use
serializers.ModelSerializerdefaults — they coverunique,max_length,MinValueValidator,MaxValueValidator,RegexValidatorfrom the field definitions, but not customclean_*methods orModel.clean().
Interview angle
- “In what order do field-level and object-level validators run?” — coercion → field built-ins → field
validators=→validate_<field>→validate()→Meta.validators. - “What’s a common gotcha with
validate_<field>?” — forgetting toreturn valuemakes the fieldNoneinvalidated_data. - “Where does
unique=Truevalidation actually happen on aModelSerializer?” — DRF auto-adds aUniqueValidatorto the field, which costs an extra DB query per write. - “How do you do cross-field validation?” —
validate(self, attrs), raise with{"field": "msg"}for field-level mapping or a plain string fornon_field_errors. - “Does DRF call
Model.clean()?” — no. Either duplicate rules in the serializer, or callfull_clean()manually and translate the exception type.