A serializer in Django REST Framework (DRF) is the layer that converts complex types — model instances and querysets — into native Python datatypes that render to JSON (and back again). It works in both directions:
- Serialization (object → dict → JSON) builds API responses from your data.
- Deserialization (incoming JSON → validate → saved object) turns request data into trusted, saved records.
Validation sits in the middle of the write path, so a serializer is also your input-validation layer. This guide covers DRF serializers in depth — the common case when people search for "Django serializers" — using current Django 5.x, DRF 3.16+, and Python 3.11+. At the end we clear up how they differ from Django's built-in django.core.serializers, which is a separate fixture/dump tool, not an API layer.
At MicroPyramid we have built and maintained DRF APIs across 12+ years and 50+ Python projects, so the patterns below are the ones we reach for in production.
Serializer vs ModelSerializer
DRF gives you two main base classes. Serializer makes you declare every field by hand; ModelSerializer generates fields, validators, and create()/update() automatically from a model.
Serializer |
ModelSerializer |
|
|---|---|---|
| Field declaration | You declare every field by hand | Auto-generated from the model |
create() / update() |
You write them yourself | Provided for you |
| Validators | Manual | Inherits model validators (unique, max_length, …) |
| Best for | Non-model data, custom shapes, query params | Standard CRUD over a Django model |
| Trade-off | More code, full control | Less code, ship faster |
Reach for ModelSerializer for normal CRUD on a model, and plain Serializer when the data does not map 1:1 to a model (search filters, aggregations, third-party payloads).
# models.py
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=120)
email = models.EmailField(unique=True)
def __str__(self):
return self.name
class Article(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
published = models.BooleanField(default=False)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="articles")
created_at = models.DateTimeField(auto_now_add=True)# serializers.py
from rest_framework import serializers
from .models import Article
# Explicit: you declare every field yourself.
class ArticleSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
title = serializers.CharField(max_length=200)
body = serializers.CharField()
published = serializers.BooleanField(default=False)
def create(self, validated_data):
return Article.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
# Auto: fields are generated from the model.
class ArticleModelSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ["id", "title", "body", "published", "author", "created_at"]
read_only_fields = ["id", "created_at"]Serialization: object to dict to JSON
Pass an instance (or a queryset with many=True) to the serializer and read .data for the JSON-ready dict. DRF's Response renders that dict to JSON for you.
>>> from .serializers import ArticleModelSerializer
>>> article = Article.objects.get(pk=1)
>>> ArticleModelSerializer(article).data
{'id': 1, 'title': 'Hello DRF', 'body': '...', 'published': True,
'author': 3, 'created_at': '2026-01-15T09:30:00Z'}
# Many objects: pass many=True
>>> qs = Article.objects.all()
>>> ArticleModelSerializer(qs, many=True).data
[{'id': 1, ...}, {'id': 2, ...}]
# In a view, hand .data to Response (it renders JSON):
from rest_framework.response import Response
Response(ArticleModelSerializer(qs, many=True).data)Deserialization: validate, then save
For writes you pass data=... instead of an instance. The flow is always the same: call is_valid(), read the cleaned validated_data, then save(). save() calls create() when there is no instance and update() when you passed one. Use partial=True for PATCH-style partial updates, and is_valid(raise_exception=True) in views to return a clean 400 automatically.
# Create
>>> data = {"title": "New post", "body": "Body text", "author": 3}
>>> serializer = ArticleModelSerializer(data=data)
>>> serializer.is_valid()
True
>>> serializer.validated_data
{'title': 'New post', 'body': 'Body text', 'author': <Author: Jane Doe>}
>>> serializer.save() # no instance -> calls create()
<Article: New post>
# Update (pass instance + data); partial=True allows missing fields
>>> serializer = ArticleModelSerializer(article, data=data, partial=True)
>>> serializer.is_valid(raise_exception=True) # -> 400 response on failure in a view
True
>>> serializer.save() # instance present -> calls update()
<Article: New post>Validation, read_only/write_only, source, SerializerMethodField
DRF runs validation in a fixed order: field-level checks (type, required, max_length), then your validate_<field> methods, then the object-level validate(self, attrs) that sees every field at once. A few field options you will use constantly:
read_only=True— appears in output, ignored on input (e.g.id, timestamps, computed values).write_only=True— accepted on input, never returned (e.g.password).required/default— control whether a field must be supplied.source— pull a value from a different attribute or a related object (source="author.name").SerializerMethodField— a read-only field computed by aget_<field>method.
from rest_framework import serializers
class ArticleModelSerializer(serializers.ModelSerializer):
# Computed, output-only (never part of validated_data).
word_count = serializers.SerializerMethodField()
# Flatten a related attribute into the response.
author_name = serializers.CharField(source="author.name", read_only=True)
class Meta:
model = Article
fields = ["id", "title", "body", "published",
"author", "author_name", "word_count"]
def get_word_count(self, obj):
return len(obj.body.split())
# Field-level: the method name MUST be validate_<field_name>.
def validate_title(self, value):
if Article.objects.filter(title__iexact=value).exists():
raise serializers.ValidationError("An article with this title already exists.")
return value
# Object-level: validate fields against each other.
def validate(self, attrs):
if attrs.get("published") and not attrs.get("body"):
raise serializers.ValidationError("Cannot publish an article with an empty body.")
return attrsA classic write_only case is a password. You accept it on input, hash it in create(), and never echo it back:
from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "password"]
extra_kwargs = {"password": {"write_only": True, "min_length": 8}}
def create(self, validated_data):
# create_user() hashes the password for you.
return User.objects.create_user(**validated_data)Nested serializers and writable nested
Embed one serializer inside another to read related objects as nested JSON. Nested input is read-only by default — DRF will not guess how to write across a relation — so to accept nested writes you override create() and update() yourself. Use many=True for a list of related objects, and the read-only depth shortcut when you only need nested output.
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ["id", "name", "email"]
class ArticleSerializer(serializers.ModelSerializer):
author = AuthorSerializer() # nested read: embeds the author object
class Meta:
model = Article
fields = ["id", "title", "body", "author"]
def create(self, validated_data):
author_data = validated_data.pop("author")
author, _ = Author.objects.get_or_create(
email=author_data["email"], defaults=author_data
)
return Article.objects.create(author=author, **validated_data)
def update(self, instance, validated_data):
author_data = validated_data.pop("author", None)
if author_data:
for attr, value in author_data.items():
setattr(instance.author, attr, value)
instance.author.save()
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance# Read-only shortcut: 'depth' auto-expands relations in OUTPUT only
# (it never makes nested input writable).
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ["id", "title", "author"]
depth = 1Relational fields
When you do not want a full nested object, DRF offers lighter ways to represent a relation. Pick based on what the client needs and whether the field must be writable.
| Field | Renders as | Writable? |
|---|---|---|
PrimaryKeyRelatedField |
The related object's PK (e.g. 3) |
Yes (needs queryset) |
StringRelatedField |
The related object's __str__ |
No (read-only) |
SlugRelatedField |
A chosen unique field (e.g. email) |
Yes (needs queryset) |
HyperlinkedModelSerializer |
API hyperlinks to related endpoints | Yes |
class ArticleSerializer(serializers.ModelSerializer):
# Default for FKs: author -> 3
author = serializers.PrimaryKeyRelatedField(queryset=Author.objects.all())
# __str__ of the related object (read-only): author -> "Jane Doe"
# author = serializers.StringRelatedField()
# A unique field on the related object: author -> "jane@example.com"
# author = serializers.SlugRelatedField(slug_field="email",
# queryset=Author.objects.all())
class Meta:
model = Article
fields = ["id", "title", "author"]
# HyperlinkedModelSerializer renders 'url' + relations as API links.
# It needs 'request' in the serializer context (DRF views add this for you).
class ArticleHyperlinkedSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Article
fields = ["url", "title", "author"]Performance: avoiding N+1 queries
Nested and related serializers are the most common source of the N+1 query problem: serializing 100 articles can fire 1 query for the articles plus 100 more for their authors. Fix it on the queryset, not in the serializer — use select_related for forward foreign-key / one-to-one relations (a SQL JOIN) and prefetch_related for reverse FK / many-to-many relations (one extra query). Set it once on a viewset's queryset so every action benefits.
For data that does not come from a model — search parameters, aggregations, external API payloads — use a plain serializers.Serializer; there is no model to introspect, so ModelSerializer does not apply.
from rest_framework import viewsets
# Without prefetching: 1 query for articles + 1 query PER article for author.
qs = Article.objects.all()
ArticleSerializer(qs, many=True).data # N+1
# With prefetching: a couple of queries total, no matter how many rows.
qs = (Article.objects
.select_related("author") # FK / one-to-one -> JOIN
.prefetch_related("tags")) # reverse FK / M2M -> 1 extra query
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related("author").prefetch_related("tags")
serializer_class = ArticleSerializer
# Non-model data: plain Serializer for query params, no model needed.
class SearchQuerySerializer(serializers.Serializer):
q = serializers.CharField(required=True)
page = serializers.IntegerField(required=False, default=1, min_value=1)
sort = serializers.ChoiceField(choices=["new", "top"], default="new")django.core.serializers is a different tool
Do not confuse DRF serializers with Django's built-in django.core.serializers. The built-in module dumps and loads whole model instances as fixtures (JSON/XML/YAML) — it is what powers dumpdata and loaddata for backups, seed data, and moving rows between databases. It does not validate arbitrary client input or build shaped API responses, so it is the wrong tool for a REST API. Reach for DRF serializers for APIs and django.core.serializers only for fixtures.
from django.core import serializers
# Serialize whole instances to a fixture string (note: a list of {model, pk, fields}).
data = serializers.serialize("json", Article.objects.all())
# Deserialize back into saveable objects.
for obj in serializers.deserialize("json", data):
obj.save() # each 'obj' is a DeserializedObject wrapper
# In practice you usually use the management commands:
# python manage.py dumpdata blog.Article --indent 2 > articles.json
# python manage.py loaddata articles.jsonWhere serializers fit in your API stack
Serializers pair with DRF views and viewsets to form the request/response cycle: the view selects the queryset and serializer, the serializer validates and shapes the data, and routers wire it to URLs. When an endpoint needs lower latency than a sync Django request can give, the same models often back a FastAPI service alongside DRF. If you want a second set of eyes on a serializer-heavy API — performance, validation rules, or a Django REST Framework upgrade — our team does this every week.
Frequently Asked Questions
What is the difference between Serializer and ModelSerializer?
Serializer requires you to declare every field and write create()/update() yourself, giving full control. ModelSerializer reads a model and auto-generates its fields, model-level validators (like unique and max_length), and the create()/update() methods. Use ModelSerializer for standard CRUD over a model and plain Serializer for data that does not map to a model, such as search filters or third-party payloads.
How do I handle writable nested serializers?
Nested serializers are read-only by default because DRF will not assume how to write across a relation. To accept nested writes, declare the nested serializer (for example author = AuthorSerializer()), then override create() and update() on the parent: pop the nested data out of validated_data, create or update the related object yourself, and attach it to the parent instance. For lists of related objects use many=True and loop over the nested items.
Why is is_valid() returning errors I do not expect?
Most surprise errors come from field options rather than your data. A field is required by default, so a missing key fails validation; mark it required=False or give it a default. A read_only field is silently ignored on input, so sending it never populates validated_data. ModelSerializer also inherits model constraints like unique and max_length, which fire automatically. Always inspect serializer.errors after is_valid() — it returns a dict keyed by field name explaining each failure.
What is the difference between read_only and write_only fields?
A read_only=True field is included in the serialized output but ignored on input — use it for server-generated values like id, timestamps, and SerializerMethodField results. A write_only=True field is accepted on input but never returned in the response — the canonical example is a password you hash in create() and never echo back. The two options are independent and a normal field is both readable and writable.
How do I avoid N+1 queries with nested serializers?
Prefetch the relations on the queryset before the serializer touches them. Use select_related for forward foreign-key and one-to-one relations (it adds a SQL JOIN) and prefetch_related for reverse foreign-key and many-to-many relations (one extra query). Set this on the viewset's queryset so every list and detail action benefits, and watch the SQL with Django Debug Toolbar to confirm the count stays flat as rows grow.
Is a DRF serializer the same as django.core.serializers?
No. django.core.serializers is a built-in module that dumps and loads whole model instances as fixtures (JSON/XML/YAML) and powers the dumpdata and loaddata management commands — it is for backups and seed data. A Django REST Framework serializer validates client input and builds shaped API responses with field-level control and relations. Use DRF serializers for APIs and django.core.serializers only for fixtures.