🔄 Serializers for Data Transformation
Serializers are the beating heart of Django REST Framework. They translate rich Django models into JSON your clients can read, validate incoming JSON on its way back into your database, and elegantly handle the messy reality of related data. Master serializers and you master DRF.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Trace the full serialization round trip: model ⇄ Python ⇄ JSON
- Choose between
Serializer,ModelSerializer, andHyperlinkedModelSerializer - Configure fields with parameters like
read_only,write_only,source, andrequired - Represent relationships five different ways and handle many-to-many fields
- Add computed fields and custom field classes
- Apply validation at the field, method, and object levels
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a read/write hybrid serializer with nested data, a computed field, and multi-level validation.
In This Lesson
What a Serializer Really Does
A Django model instance is a rich Python object with dates, decimals, foreign keys, and methods. JSON knows only strings, numbers, booleans, lists, and objects. A serializer is the bilingual translator that moves data faithfully between those two worlds — in both directions.
💡 The universal translator. Picture the translator from science fiction that lets two species speak. Serializers do the same for Django models and API clients: serialization converts a model to JSON for a response, and deserialization converts incoming JSON back into a validated model for a request. And like a good translator, it preserves meaning — enforcing validation and data integrity, not just swapping words.
📖 Two directions, one class
Serialization: Django model → Python native types → JSON (for responses).
Deserialization: JSON → Python native types → validated data → model (for requests).
The Serialization Round Trip
Data makes a complete loop through a serializer. Understanding each stage tells you where to hook in customisation and validation:
- Serialization — a model instance becomes a Python dict of native types.
- Rendering — that dict is rendered to a wire format such as JSON.
- Parsing — an incoming JSON body is parsed back into a Python dict.
- Validation — the parsed data is checked against your rules.
- Deserialization — valid data is turned into a model instance via
create()orupdate().
In code, you access these through a small, consistent API:
# Serialize (model -> data)
serializer = BookSerializer(book)
serializer.data # -> {"id": 1, "title": "Dune", ...}
# Deserialize (data -> model), always validate first
serializer = BookSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # 400 with error detail if invalid
serializer.save() # calls .create() or .update()
serializer.validated_data # the cleaned Python data
Types of Serializers
DRF offers several serializer base classes, trading flexibility for brevity.
1. Serializer — full control, most code
You declare every field and write create()/update() yourself. Reach for this when your data doesn't map cleanly to a single model.
from rest_framework import serializers
from .models import Book
class BookSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
title = serializers.CharField(max_length=200)
author = serializers.CharField(max_length=100)
published_date = serializers.DateField()
isbn = serializers.CharField(max_length=13)
def create(self, validated_data):
return Book.objects.create(**validated_data)
def update(self, instance, validated_data):
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
2. ModelSerializer — the everyday workhorse
Generates fields, validators, and create()/update() automatically from the model. This is what you'll use 90% of the time.
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "author", "published_date", "isbn"]
# fields = "__all__" # include every model field
# exclude = ["created_at"] # or take all but a few
⚠️ Prefer an explicit fields list
fields = "__all__" is convenient but risky: add a sensitive column to the model later (say, an internal note) and it silently leaks into your API. Listing fields explicitly makes the API surface a deliberate decision.
3. HyperlinkedModelSerializer
Like ModelSerializer, but relationships are shown as URLs rather than primary keys — a good fit for discoverable, HATEOAS-style APIs.
class BookSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Book
fields = ["url", "id", "title", "author", "published_date", "isbn"]
4. ListSerializer (via many=True)
You rarely instantiate this directly — passing many=True wraps your serializer in a ListSerializer to handle a collection.
serializer = BookSerializer(books, many=True) # serialize a queryset
Fields & Their Parameters
Serializer fields mirror model fields but are about representation, not storage. Common field types:
| Field | For | Typical parameters |
|---|---|---|
CharField | Text | max_length, trim_whitespace |
IntegerField | Whole numbers | min_value, max_value |
DecimalField | Money, precise numbers | max_digits, decimal_places |
BooleanField | True/false | default |
DateField / DateTimeField | Dates & times | format, input_formats |
EmailField / URLField | Validated strings | max_length |
SerializerMethodField | Computed, read-only values | method_name |
Parameters every field accepts
read_only— appears in output, ignored on input (e.g.id, timestamps).write_only— accepted on input, hidden from output (e.g. passwords).required— may the field be omitted on input?default— value used when the field is absent.allow_null/allow_blank— permitnull/ empty string.source— the model attribute to read from (supports dotted paths likeauthor.name).validators— a list of validator callables.help_text— description shown in the browsable API.
class BookSerializer(serializers.ModelSerializer):
title = serializers.CharField(max_length=200, help_text="The book's title")
summary = serializers.CharField(
required=False, allow_blank=True, default="No summary available.",
)
class Meta:
model = Book
fields = ["id", "title", "author", "published_date", "isbn", "summary"]
Handling Relationships
Relationships are where serializers earn their keep. Given an Author and a Book with a foreign key, DRF offers several ways to represent the link — each a different balance of readability and writability.
# models.py
class Author(models.Model):
name = models.CharField(max_length=100)
biography = models.TextField(blank=True)
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
published_date = models.DateField()
isbn = models.CharField(max_length=13)
| Approach | Output for author | Writable? |
|---|---|---|
PrimaryKeyRelatedField | 1 | Yes |
StringRelatedField | "Jane Austen" | No (read-only) |
| Nested serializer | {"id":1,"name":"Jane Austen",…} | Only with custom create/update |
HyperlinkedRelatedField | "http://…/authors/1/" | Yes |
SlugRelatedField | "jane-austen" | Yes (single field) |
The pragmatic favourite: read-nested, write-by-id
Clients usually want to read the full related object but only need to send an ID when writing. The hybrid pattern gives you both, cleanly:
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ["id", "name", "biography"]
class BookSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True) # rich object in responses
author_id = serializers.PrimaryKeyRelatedField( # just an id on input
queryset=Author.objects.all(), source="author", write_only=True,
)
class Meta:
model = Book
fields = ["id", "title", "author", "author_id", "published_date", "isbn"]
Many-to-many relationships
The same pattern scales to M2M — just add many=True. Suppose a book has many Tags:
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name"]
class BookSerializer(serializers.ModelSerializer):
tags = TagSerializer(many=True, read_only=True) # full tags out
tag_ids = serializers.PrimaryKeyRelatedField( # list of ids in
queryset=Tag.objects.all(), source="tags",
many=True, write_only=True,
)
class Meta:
model = Book
fields = ["id", "title", "tags", "tag_ids"]
💡 source is the glue
Naming the input field author_id but setting source="author" lets the API accept a friendly key while still populating the real model attribute. Without source, DRF would look for a model field literally called author_id.
Computed & Custom Fields
Not every value in your API maps to a stored column. Two tools cover the gap.
SerializerMethodField — read-only computed values
Define a get_<field_name> method that receives the object and returns the value.
class BookSerializer(serializers.ModelSerializer):
author_name = serializers.ReadOnlyField(source="author.name")
is_recent = serializers.SerializerMethodField()
ratings_summary = serializers.SerializerMethodField()
class Meta:
model = Book
fields = ["id", "title", "author_name", "published_date",
"is_recent", "ratings_summary"]
def get_is_recent(self, obj):
return obj.published_date.year >= 2020
def get_ratings_summary(self, obj):
ratings = obj.ratings.all()
if not ratings:
return {"count": 0, "average": None}
count = ratings.count()
return {"count": count,
"average": round(sum(r.score for r in ratings) / count, 1)}
Custom field classes — reshape a value both ways
Subclass serializers.Field and implement two methods: to_representation() (out) and to_internal_value() (in). Here we display an ISBN with hyphens but store only digits:
class ISBNField(serializers.Field):
def to_representation(self, value):
if len(value) == 13:
return f"{value[0:3]}-{value[3]}-{value[4:9]}-{value[9:12]}-{value[12]}"
return value
def to_internal_value(self, data):
if not isinstance(data, str):
raise serializers.ValidationError("ISBN must be a string.")
digits = "".join(c for c in data if c.isdigit())
if len(digits) not in (10, 13):
raise serializers.ValidationError("ISBN must be 10 or 13 digits.")
return digits
class BookSerializer(serializers.ModelSerializer):
isbn = ISBNField()
class Meta:
model = Book
fields = ["id", "title", "isbn"]
Three Layers of Validation
DRF validates incoming data at three complementary levels. Use whichever fits the rule you're expressing.
validate().1. Field-level validators (reusable functions)
def validate_isbn(value):
if not value.isdigit():
raise serializers.ValidationError("ISBN must contain only digits.")
if len(value) != 13:
raise serializers.ValidationError("ISBN must be 13 digits long.")
return value
class BookSerializer(serializers.ModelSerializer):
isbn = serializers.CharField(validators=[validate_isbn])
class Meta:
model = Book
fields = ["id", "title", "isbn"]
2. Per-field validate_<field> methods
import datetime
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "published_date"]
def validate_published_date(self, value):
if value > datetime.date.today():
raise serializers.ValidationError("Published date can't be in the future.")
return value
3. Object-level validate() (compare multiple fields)
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "author", "published_date"]
def validate(self, data):
# Prevent the same author having two books with the same title
if Book.objects.filter(author=data["author"], title=data["title"]).exists():
raise serializers.ValidationError(
{"title": "This author already has a book with this title."}
)
return data
Hands-on Exercise
🏋️ A Blog Post Serializer with Everything
Objective: Combine relationships, a computed field, and validation in one serializer.
Scenario models:
class Author(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="posts")
created_at = models.DateTimeField(auto_now_add=True)
Build a PostSerializer that:
- Shows the full author object on read, but accepts an
author_idon write. - Adds a read-only
reading_timecomputed field (assume 200 words/minute, based on the body's word count). - Rejects any
titleshorter than 5 characters with a helpful message. - Makes
created_atread-only.
💡 Hint
Use the read-nested / write-by-id hybrid from Section 5 for the author. For reading time, a SerializerMethodField with get_reading_time(self, obj) returning max(1, round(len(obj.body.split()) / 200)) works well. Validate the title with a validate_title method.
✅ Sample solution
from rest_framework import serializers
from .models import Author, Post
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ["id", "name"]
class PostSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True)
author_id = serializers.PrimaryKeyRelatedField(
queryset=Author.objects.all(), source="author", write_only=True,
)
reading_time = serializers.SerializerMethodField()
class Meta:
model = Post
fields = ["id", "title", "body", "author", "author_id",
"reading_time", "created_at"]
read_only_fields = ["created_at"]
def get_reading_time(self, obj):
words = len(obj.body.split())
return max(1, round(words / 200))
def validate_title(self, value):
if len(value) < 5:
raise serializers.ValidationError("Title must be at least 5 characters.")
return value
🎯 Quick Quiz
Question 1: Which serializer field parameter makes a field appear in responses but be ignored in incoming data?
Question 2: You want to compare two fields against each other during validation. Which hook do you use?
Question 3: What does adding many=True when instantiating a serializer do?
Summary & Quiz
🎉 Key Takeaways
- Serializers handle a full round trip: model → JSON out, and JSON → validated model in.
- ModelSerializer is the everyday choice; list
fieldsexplicitly to control your API surface. - Field parameters (
read_only,write_only,source,required) shape each field's behaviour. - Relationships can be IDs, strings, nested objects, hyperlinks, or slugs — the read-nested / write-by-id hybrid is the pragmatic favourite.
- SerializerMethodField and custom field classes add computed and reshaped values.
- Validation runs at three levels: field, method, and object.
📚 Further Reading
🚀 What's Next?
You can now transform data with confidence. Next we'll wire these serializers into ViewSets and Routers to generate entire sets of RESTful endpoints from just a few lines of code.
🎉 Serializers: mastered!
The hardest part of DRF is behind you. Time to make endpoints appear almost for free.