Serializers & ViewSets
Reviewed & published by Brayan K
In Django REST Framework, a serializer converts model instances to and from JSON — validating incoming data and shaping outgoing data — while a ViewSet bundles the standard list, create, retrieve, update, and delete actions into one class that a router can wire up to URLs automatically.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll build a ModelSerializer for a model, expose full CRUD through a ModelViewSet, and let a DefaultRouter generate all the URLs so you write almost no routing code by hand.
What You'll Learn in This Lesson
- What a serializer does and why APIs need one
- Writing a ModelSerializer with an inner Meta class
- Serializing objects out and deserializing data in
- Validating input with is_valid() before save()
- Getting full CRUD for free with a ModelViewSet
- Auto-generating URLs with a DefaultRouter
1 ModelSerializer: Converting Models to JSON
A serializer is the translator between your Django models and the JSON your API speaks. Going out, it turns a model instance into a dictionary of primitive values ready to become JSON. Coming in, it takes raw request data, validates it, and turns it back into a saved model instance.
The fastest way to write one is the ModelSerializer. You point it at a model and list the fields you want in an inner Meta class, and it generates the field definitions plus create() and update() for you.
# articles/serializers.py
from rest_framework import serializers
from .models import Article
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ["id", "title", "body", "published"]
# Serialize OUT: model instance -> JSON-ready dict
article = Article.objects.get(pk=1)
serializer = ArticleSerializer(article)
serializer.data # {"id": 1, "title": "Hi", "body": "...", "published": True}
# Deserialize IN: raw data -> validated, saved instance
incoming = {"title": "New", "body": "Words", "published": False}
serializer = ArticleSerializer(data=incoming)
if serializer.is_valid():
serializer.save() # creates the Article in the database
else:
print(serializer.errors)# A serializer is really just controlled translation. Here's the idea
# in plain Python so you can run it: serialize OUT, then deserialize IN.
import json
# Pretend this is a model instance (an object with attributes)
class Article:
def __init__(self, id, title, body, published):
self.id = id
self.title = title
self.body = body
self.published = published
FIELDS = ["id", "title", "body", "published"]
def serialize(instance):
"""Like serializer.data — model instance -> dict -> JSON."""
data = {field: getattr(instance, field) for field in FIELDS}
return json.dumps(data)
article = Article(1, "Hello DRF", "First post", True)
print("Outgoing JSON:", serialize(article))
# Deserialize IN: JSON text -> Python dict you could save
incoming = '{"title": "Draft", "body": "WIP", "published": false}'
parsed = json.loads(incoming)
print("Parsed title:", parsed["title"])
print("Parsed published:", parsed["published"])
# ✅ Expected output:
# Outgoing JSON: {"id": 1, "title": "Hello DRF", "body": "First post", "published": true}
# Parsed title: Draft
# Parsed published: False2 ModelViewSet: Full CRUD in One Class
A ViewSet groups related views into a single class. The ModelViewSet goes further: give it a queryset and a serializer_class and it provides every CRUD action — list, create, retrieve, update, partial_update, and destroy — automatically.
# articles/views.py
from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
# That's it. This one class now handles:
# GET /articles/ -> list
# POST /articles/ -> create
# GET /articles/{pk}/ -> retrieve
# PUT /articles/{pk}/ -> update
# PATCH /articles/{pk}/ -> partial_update
# DELETE /articles/{pk}/ -> destroyThe ViewSet uses the serializer_class for both directions: it serializes the queryset on the way out and validates request data on the way in, calling .is_valid() and .save() for you.
# A ModelViewSet maps an HTTP method + URL to one of five actions.
# Here's that dispatch logic in plain Python so you can see the routing.
def dispatch(method, has_pk):
"""Pick the ViewSet action the way DRF's router does."""
if not has_pk:
return {"GET": "list", "POST": "create"}.get(method, "405")
return {
"GET": "retrieve",
"PUT": "update",
"PATCH": "partial_update",
"DELETE": "destroy",
}.get(method, "405")
print("GET /articles/ ->", dispatch("GET", has_pk=False))
print("POST /articles/ ->", dispatch("POST", has_pk=False))
print("GET /articles/1/ ->", dispatch("GET", has_pk=True))
print("DELETE /articles/1/ ->", dispatch("DELETE", has_pk=True))
# One ModelViewSet class handles all five of these for you.
# ✅ Expected output:
# GET /articles/ -> list
# POST /articles/ -> create
# GET /articles/1/ -> retrieve
# DELETE /articles/1/ -> destroy3 DefaultRouter: Automatic URLs
Writing URL patterns for every CRUD action by hand is tedious. A DefaultRouter inspects a registered ViewSet and generates all of them for you, plus a browsable API root at the base URL.
# articles/urls.py
from rest_framework.routers import DefaultRouter
from .views import ArticleViewSet
router = DefaultRouter()
router.register(r"articles", ArticleViewSet, basename="article")
# Include the router's generated patterns in your project urls.py
urlpatterns = router.urlsFrom that single register() call, the router builds these URLs:
^articles/$ -> list, create (name: article-list)
^articles/{pk}/$ -> retrieve, update,
partial_update,
destroy (name: article-detail)
^$ -> the API root view (DefaultRouter only)The basename argument controls the names DRF uses when reversing URLs (for example article-list and article-detail). You can omit it when the ViewSet has a queryset, but passing it explicitly is good practice.
# A router is essentially a registry that expands one entry into many
# URL patterns. Here's a tiny version of register() + url generation.
class MiniRouter:
def __init__(self):
self.routes = []
def register(self, prefix, basename):
self.routes.append((prefix, basename))
def urls(self):
patterns = []
for prefix, base in self.routes:
patterns.append((f"{prefix}/", f"{base}-list")) # list + create
patterns.append((f"{prefix}/<pk>/", f"{base}-detail")) # retrieve...destroy
return patterns
router = MiniRouter()
router.register("articles", "article")
for path, name in router.urls():
print(f"{path:20} -> {name}")
# EXPECTED:
# articles/ -> article-list
# articles/<pk>/ -> article-detail
# ✅ Expected output:
# articles/ -> article-list
# articles/<pk>/ -> article-detail🎯 Your Turn: Validate Before You Save
Fill in the blank with the method name you must call before reading validated data or saving. In DRF this is the serializer method that runs all validation.
# 🎯 YOUR TURN: replace ___ with the method that must run before save().
# In DRF you write: if serializer.is_valid(): serializer.save()
REQUIRED = ["title", "body"]
class FakeSerializer:
def __init__(self, data):
self.data = data
self.errors = {}
def is_valid(self):
for field in REQUIRED:
if not self.data.get(field):
self.errors[field] = "This field is required."
return len(self.errors) == 0
def save(self):
return f"Saved: {self.data['title']}"
s = FakeSerializer({"title": "Hello", "body": "Words"})
# Replace ___ with the method name (no parentheses needed in the string)
method_to_call = "___"
print("You must call:", method_to_call, "before save()")
if s.is_valid():
print(s.save())
# EXPECTED OUTPUT:
# You must call: is_valid before save()
# Saved: HelloCommon Errors (And How to Fix Them)
❌ AssertionError: cannot access serializer.data before is_valid()
You created the serializer with data= but tried to read validated values before validating.
✅ Fix: call serializer.is_valid() first, then read serializer.validated_data or call serializer.save().
❌ 404 Not Found on every endpoint
You registered the ViewSet on the router but never added the router's URLs to urlpatterns.
✅ Fix: include them — urlpatterns = router.urls (or path("", include(router.urls))).
❌ A field is missing from the JSON response
The field exists on the model but was never listed in the serializer's Meta.fields.
✅ Fix: add the field name to Meta.fields (or use fields = "__all__" to include every model field).
📋 Quick Reference
| Tool | What It Does |
|---|---|
| ModelSerializer | Builds fields from a model via inner Meta; converts to/from JSON |
| ModelViewSet | Full CRUD from a queryset + serializer_class |
| DefaultRouter | Generates URLs for a registered ViewSet plus an API root |
| is_valid() | Runs validation; fills validated_data and errors |
| save() | Creates or updates the instance from validated data |
🏆 Mini Challenge: Build the Deserialize Flow
Simulate the full deserialize-and-save flow DRF runs: validate the incoming data, then either save it or report the errors.
# 🏆 Mini Challenge: mimic serializer = S(data=...); is_valid(); save()
import json
REQUIRED = ["title", "body"]
class ArticleSerializer:
def __init__(self, data):
self._raw = data
self.validated_data = {}
self.errors = {}
def is_valid(self):
for field in REQUIRED:
value = self._raw.get(field)
if not value:
self.errors[field] = "This field is required."
if not self.errors:
self.validated_data = dict(self._raw)
return not self.errors
def save(self):
# Pretend we wrote a row and got back an id
saved = {"id": 99, **self.validated_data}
return json.dumps(saved)
def handle(request_body):
serializer = ArticleSerializer(data=request_body)
if serializer.is_valid():
return "201 CREATED " + serializer.save()
return "400 BAD REQUEST " + json.dumps(serializer.errors)
print(handle({"title": "Good", "body": "Has both fields"}))
print(handle({"title": "Oops"})) # missing body
# EXPECTED:
# 201 CREATED {"id": 99, "title": "Good", "body": "Has both fields"}
# 400 BAD REQUEST {"body": "This field is required."}❓ Frequently Asked Questions
Lesson complete — your API writes itself!
You can now build a ModelSerializer with an inner Meta, serialize objects out and deserialize data in, validate with is_valid() before save(), expose full CRUD with a ModelViewSet, and auto-generate URLs with a DefaultRouter.
Practice quiz
What does a serializer do in Django REST Framework?
- Converts model instances to and from JSON
- Routes URLs to views
- Runs database migrations
- Renders HTML templates
Answer: Converts model instances to and from JSON. A serializer translates between model instances and JSON, validating data both ways.
Where do you declare the model and fields on a ModelSerializer?
- In the __init__ method
- In an inner Meta class
- In settings.py
- In a urls.py entry
Answer: In an inner Meta class. A ModelSerializer reads model and fields from its inner Meta class.
Which serializer method must you call before reading validated_data or saving?
- clean()
- validate_all()
- is_valid()
- full_clean()
Answer: is_valid(). is_valid() runs validation and populates validated_data and errors; it must run first.
What advantage does ModelSerializer have over a plain Serializer?
- It is faster at runtime only
- It encrypts data
- It skips validation
- It builds fields and create()/update() automatically from the model
Answer: It builds fields and create()/update() automatically from the model. ModelSerializer generates field definitions plus create() and update() from the model.
Which class provides full CRUD actions from a queryset and serializer_class?
- APIView
- ModelViewSet
- TemplateView
- Serializer
Answer: ModelViewSet. ModelViewSet supplies list, create, retrieve, update, partial_update, and destroy.
Which HTTP method maps to the ViewSet 'create' action on the list URL?
- POST
- GET
- PUT
- DELETE
Answer: POST. POST to the collection URL maps to the create action.
Which router also generates a browsable API root view?
- SimpleRouter
- BaseRouter
- DefaultRouter
- PlainRouter
Answer: DefaultRouter. DefaultRouter adds an API root view linking to every registered endpoint.
What is raised if you access validated_data before calling is_valid()?
- A ValueError
- A 404 response
- Nothing happens
- An AssertionError
Answer: An AssertionError. Accessing validated_data before is_valid() raises an AssertionError.
How do you register a ViewSet with a router?
- router.register(r'articles', ArticleViewSet)
- router.add(ArticleViewSet)
- router.include(ArticleViewSet)
- router.route('articles')
Answer: router.register(r'articles', ArticleViewSet). router.register(prefix, ViewSet, basename=...) wires the ViewSet to generated URLs.
Which Meta.fields value includes every model field?
- fields = ['*']
- fields = '__all__'
- fields = 'all'
- fields = None
Answer: fields = '__all__'. Setting fields = '__all__' includes every field on the model.
Continue this course
- Previous: Django REST Framework Intro
- Next: Signals — React to model events automatically with Django's signal dispatcher
- Quick reference: Django cheat sheet
Frequently asked questions
What is the difference between a Serializer and a ModelSerializer?
A plain Serializer requires you to declare every field by hand and write your own create() and update() methods. A ModelSerializer reads its fields directly from a Django model, so you only specify the model and the list of fields in an inner Meta class. It also generates create() and update() automatically, which makes it the fastest way to build an API for an existing model.
When should I use a ViewSet instead of an APIView?
Use a ViewSet when you want the standard set of CRUD actions — list, create, retrieve, update, and destroy — for a resource. A ModelViewSet provides all of them out of the box once you give it a queryset and a serializer_class. Reach for an APIView only when you need custom, non-CRUD behaviour that does not map cleanly onto those five actions.
Why do I have to call is_valid() before accessing validated_data?
Serializer validation is lazy. When you create a serializer from incoming data, nothing is checked until you call is_valid(). That method runs all field and object-level validation and populates serializer.validated_data and serializer.errors. Accessing validated_data before calling is_valid() raises an AssertionError because the data has not been validated yet.
What does a DefaultRouter give me that I don't get from a SimpleRouter?
Both routers generate the standard list and detail URLs for a registered ViewSet. A DefaultRouter additionally creates an API root view at the base URL that links to every registered endpoint, and it supports optional format suffixes like .json. The browsable, self-documenting API root is the main reason most projects choose DefaultRouter during development.