---
title: "DRF Serializers Done Right: Validation, Nested Writes and Speed"
url: "https://nareshkumar.online/blog/drf-serializers-validation-nested-writes"
author: "Naresh Kumar"
published: "2026-10-05"
updated: "2026-10-05"
category: "Engineering"
tags: ["API Design", "Django", "Django REST Framework", "Performance", "Python"]
---

# DRF Serializers Done Right: Validation, Nested Writes and Speed

**In short:** Shape fields with read_only, write_only and source. Validate single values in validate_<field> and cross-field rules in validate(). Write nested data yourself inside transaction.atomic, and keep queries out of SerializerMethodField.

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`

```python
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"]
```

> **Tip:** Always list `fields` explicitly. `fields = "__all__"` exposes every new model column the moment it is added, including ones you never meant to make public.

## 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`

```python
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.

> **Note:** Rules that depend on the database at write time, such as stock levels or double bookings, can race between validation and saving. Back them with a database constraint (`UniqueConstraint`, `CheckConstraint`) or a row lock, and let the serializer produce the friendly message.

## 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`

```python
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](https://nareshkumar.online/blog/django-n-plus-one-queries) 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`

```python
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](https://nareshkumar.online/blog/drf-authentication-permissions-jwt)
3. [Pagination, filtering and search in DRF](https://nareshkumar.online/blog/drf-pagination-filtering-search)
4. [Fixing N+1 queries with select\_related and prefetch\_related](https://nareshkumar.online/blog/django-n-plus-one-queries)
5. [Testing DRF APIs with pytest](https://nareshkumar.online/blog/testing-drf-apis-with-pytest)
