DRF Validation

5 interview angles 3 min read source

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()

  1. Per-field to_internal_value (type coercion: "42"42, ISO strings → datetimes). A failure here means the field never reaches your validators.
  2. Per-field built-in validators (max_length, min_value, UniqueValidator, etc.).
  3. Per-field validators=[...] list passed to the field.
  4. Field-level validate_<fieldname>(self, value) method on the serializer.
  5. Object-level validate(self, attrs) method on the serializer.
  6. UniqueTogetherValidator / UniqueForDateValidator from Meta.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:

  1. Duplicate the rule in the serializer (most explicit).
  2. Call instance.full_clean() inside .create() / .update() and translate django.core.exceptions.ValidationError into rest_framework.exceptions.ValidationError.
  3. Use serializers.ModelSerializer defaults — they cover unique, max_length, MinValueValidator, MaxValueValidator, RegexValidator from the field definitions, but not custom clean_* methods or Model.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 to return value makes the field None in validated_data.
  • “Where does unique=True validation actually happen on a ModelSerializer?” — DRF auto-adds a UniqueValidator to 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 for non_field_errors.
  • “Does DRF call Model.clean()?” — no. Either duplicate rules in the serializer, or call full_clean() manually and translate the exception type.