On this page
- Serializer or ModelSerializer?
- Control each field: read_only, write_only and source
- Validation in three layers
- Use the request inside a serializer
- Nested serializers: easy to read, explicit to write
- Read nested, write by id
- Shape the output with to_representation
- Keep list responses fast
- Frequently asked questions
- 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.
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 examplesource="customer.email".required=False,allow_null=Trueanddefault=...: decide what happens when a value is missing or null.
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:
- Each field checks its type and options, such as
max_lengthormin_value, and runs anyvalidators=[...]you attached. validate_<field_name>()methods run for single-field rules that need custom code.- Serializer-level validators, such as
UniqueTogetherValidator, run next, followed byvalidate(), which receives all the values inattrsand holds the rules that involve several fields.
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 attrsRaising 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:
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:
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":
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 instanceReplacing 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.
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:
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 dataKeep list responses fast#
- Fetch relations in the view. Every
source="a.b"field and every nested serializer reads a relation. Addselect_related()andprefetch_related()inget_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 forlistand the full one elsewhere.
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 OrderSerializerFrequently 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:
- DRF serializers: validation, nested writes and speed (this post)
- Authentication, permissions and JWT in DRF
- Pagination, filtering and search in DRF
- Fixing N+1 queries with select_related and prefetch_related
- Testing DRF APIs with pytest
Comments
No comments yet. Be the first.