Engineering

DRF Serializers Done Right: Validation, Nested Writes and Speed

On this page
  1. Serializer or ModelSerializer?
  2. Control each field: read_only, write_only and source
  3. Validation in three layers
  4. Use the request inside a serializer
  5. Nested serializers: easy to read, explicit to write
  6. Read nested, write by id
  7. Shape the output with to_representation
  8. Keep list responses fast
  9. Frequently asked questions
  10. More in this series

Serializers do two jobs in Django REST Framework. They turn model instances into JSON for responses, and they turn request data into validated Python values before anything touches the database. Most serializer bugs come from mixing those jobs up: a field that should be read-only accepts input, a rule is checked in the wrong place, or a list endpoint quietly runs a query per row.

This guide covers the parts of serializers you use every day, with patterns that hold up in production. The examples use a small shop: users, products, orders and bookings.

Serializer or ModelSerializer?#

ModelSerializer builds fields, validators and default create() and update() methods from a model. Use it for anything that maps to a model. Use a plain Serializer for input that doesn't, such as a login form, a search request or a bulk action.

serializers.py
from rest_framework import serializers

from .models import Order


class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["id", "customer", "status", "notes", "created_at"]
        read_only_fields = ["status", "created_at"]

Control each field: read_only, write_only and source#

  • read_only=True: returned in responses, ignored in input. Use it for ids, timestamps and values the server computes.
  • write_only=True: accepted in input, never returned. Use it for passwords and secrets.
  • source: read the value from another attribute, a method or a related object, for example source="customer.email".
  • required=False, allow_null=True and default=...: decide what happens when a value is missing or null.
python
from django.contrib.auth import get_user_model
from rest_framework import serializers

User = get_user_model()


class UserSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=12)
    full_name = serializers.CharField(source="get_full_name", read_only=True)

    class Meta:
        model = User
        fields = ["id", "username", "email", "full_name", "password"]

    def create(self, validated_data):
        return User.objects.create_user(**validated_data)

The create() override matters. The default one from ModelSerializer would store the password exactly as sent, while create_user() hashes it.

To change options on generated fields without redeclaring them, use extra_kwargs in Meta, for example extra_kwargs = {"email": {"required": True}}.

Validation in three layers#

DRF validates in a fixed order when you call is_valid(). Knowing that order tells you where each rule belongs:

  1. Each field checks its type and options, such as max_length or min_value, and runs any validators=[...] you attached.
  2. validate_<field_name>() methods run for single-field rules that need custom code.
  3. Serializer-level validators, such as UniqueTogetherValidator, run next, followed by validate(), which receives all the values in attrs and holds the rules that involve several fields.
serializers.py
from django.utils import timezone
from rest_framework import serializers

from .models import Booking


class BookingSerializer(serializers.ModelSerializer):
    class Meta:
        model = Booking
        fields = ["id", "room", "starts_at", "ends_at", "guests"]

    def validate_guests(self, value):
        if value > 8:
            raise serializers.ValidationError("A booking can have at most 8 guests.")
        return value

    def validate_starts_at(self, value):
        if value < timezone.now():
            raise serializers.ValidationError("Must be in the future.")
        return value

    def validate(self, attrs):
        # On a partial update (PATCH), attrs holds only the fields that were sent.
        starts_at = attrs.get("starts_at", getattr(self.instance, "starts_at", None))
        ends_at = attrs.get("ends_at", getattr(self.instance, "ends_at", None))
        if starts_at and ends_at and ends_at <= starts_at:
            raise serializers.ValidationError({"ends_at": "Must be after the start time."})
        return attrs

Raising ValidationError with a dict attaches each message to a field, so a client can show it next to the right input. Always return the value from validate_<field>() and attrs from validate(): what you return is what gets saved.

One detail trips people up: validate() only runs when every field is valid. If the room id doesn't exist, the client gets that error alone, and the date check waits for the next attempt.

Use the request inside a serializer#

Generic views and viewsets pass the request to the serializer in its context, available as self.context["request"]. For the common case of stamping the current user on a new object, a hidden field is cleaner:

python
class CommentSerializer(serializers.ModelSerializer):
    author = serializers.HiddenField(default=serializers.CurrentUserDefault())

    class Meta:
        model = Comment
        fields = ["id", "post", "body", "author", "created_at"]
        read_only_fields = ["created_at"]

A HiddenField never appears in input or output, so a client can't claim to be someone else. The alternative is serializer.save(author=self.request.user) in the view's perform_create().

Nested serializers: easy to read, explicit to write#

Nesting one serializer inside another gives clients the related data in one response:

python
class OrderItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = OrderItem
        fields = ["product", "quantity"]


class OrderSerializer(serializers.ModelSerializer):
    items = OrderItemSerializer(many=True, allow_empty=False)

    class Meta:
        model = Order
        fields = ["id", "customer", "items"]

Reading works out of the box. Writing doesn't: the default create() raises an error for nested data, because DRF can't guess how the related rows should be created, updated or deleted. Write that logic yourself, and wrap it in a transaction so a failure halfway leaves nothing behind. Here OrderItem.order has related_name="items":

serializers.py
from django.db import transaction


class OrderSerializer(serializers.ModelSerializer):
    items = OrderItemSerializer(many=True, allow_empty=False)

    class Meta:
        model = Order
        fields = ["id", "customer", "items"]

    @transaction.atomic
    def create(self, validated_data):
        items = validated_data.pop("items")
        order = Order.objects.create(**validated_data)
        OrderItem.objects.bulk_create(OrderItem(order=order, **item) for item in items)
        return order

    @transaction.atomic
    def update(self, instance, validated_data):
        items = validated_data.pop("items", None)
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        instance.save()
        if items is not None:
            instance.items.all().delete()
            OrderItem.objects.bulk_create(OrderItem(order=instance, **item) for item in items)
        return instance

Replacing all items on update is the simplest rule that is always correct. If clients rely on item ids, match incoming items to existing ones by id instead.

Read nested, write by id#

When clients only need to point at an existing object, use two fields: a nested serializer for reading and a PrimaryKeyRelatedField for writing.

python
class BookSerializer(serializers.ModelSerializer):
    author = AuthorSerializer(read_only=True)
    author_id = serializers.PrimaryKeyRelatedField(
        source="author", queryset=Author.objects.all(), write_only=True
    )

    class Meta:
        model = Book
        fields = ["id", "title", "author", "author_id"]

Shape the output with to_representation#

Override to_representation() when a response needs a different shape than the fields give you, for example to hide a field from most users:

python
    def to_representation(self, instance):
        data = super().to_representation(instance)
        request = self.context.get("request")
        if not (request and request.user.is_staff):
            data.pop("internal_notes", None)
        return data

Keep list responses fast#

  • Fetch relations in the view. Every source="a.b" field and every nested serializer reads a relation. Add select_related() and prefetch_related() in get_queryset(). The N+1 guide explains which one to use.
  • No queries in SerializerMethodField. Its method runs once per object. Compute the value with an annotation in the queryset and read it from the instance.
  • Slimmer serializers for lists. A list rarely needs every field. Override get_serializer_class() to use a light serializer for list and the full one elsewhere.
views.py
from django.db.models import Count
from rest_framework import viewsets


class OrderViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return (
            Order.objects.select_related("customer")
            .prefetch_related("items")
            .annotate(item_count=Count("items"))
        )

    def get_serializer_class(self):
        if self.action == "list":
            return OrderListSerializer
        return OrderSerializer

Frequently asked questions#

Where should validation live: in the serializer or in the model?

Put rules about the API's input in the serializer. Put rules that must hold for every write path, such as the admin, scripts and other APIs, in the model or in database constraints. DRF doesn't call Model.full_clean(), so a model's clean() method doesn't run when the API saves.

How do I make a field required only when creating?

Check self.instance in validate(): it is None on create and the existing object on update. For very different rules, use separate serializers for create and update.

What does partial=True do?

It makes every field optional, which is what PATCH requests need. UpdateModelMixin sets it for partial_update. Remember that validate() then sees only the fields that were sent.

Why does saving a nested serializer raise an error?

The default create() and update() don't support writable nested fields, because the right behavior differs between APIs. Override them, pop the nested data, and save the related rows yourself inside transaction.atomic.

How do I change the format of validation errors?

Errors come back as a dict of field names to lists of messages. To change that for the whole API, point EXCEPTION_HANDLER in your REST_FRAMEWORK settings at a function that wraps DRF's default handler.

More in this series#

This is part 1 of 5 in Django REST Framework in practice:

  1. DRF serializers: validation, nested writes and speed (this post)
  2. Authentication, permissions and JWT in DRF
  3. Pagination, filtering and search in DRF
  4. Fixing N+1 queries with select_related and prefetch_related
  5. Testing DRF APIs with pytest

Comments

No comments yet. Be the first.

Leave a comment

Only used to tell you about a reply. Never shown.

Plain text; line breaks are kept.

Let's build something

Hiring for a backend role, or have a project in mind? Send a message and I will reply by email.

At most 2 links.