Django DRF #1: Getting Started — Serializer, ViewSet, Router
This is the series where everything from Django Basics → Intermediate → Advanced comes together. While full-stack Django uses templates/forms/sessions as-is, DRF (Django REST Framework) adds a clean REST API layer on top of that same ORM/auth foundation.
- #1 Getting Started — Serializer, ViewSet, Router ← this post
- #2 Authentication/Permissions — Token, JWT, custom permission
- #3 Filtering / Ordering / Pagination
- #4 Async work with Celery
- #5 OpenAPI docs (drf-spectacular)
- #6 Testing and deployment — Docker, gunicorn, nginx
What you’ll build is a small Blog API — Post, Comment, Author domain. Post CRUD, comments, auth, search/pagination, heavy work with Celery, automatic schema docs, and finally container deployment. Each post stacks one more layer on top.
Where DRF fits — how it differs from full-stack Django #
The full-stack Django from Basics #4 ~ #6 was a template + form + server rendering flow. When a user submitted a form in the browser, the view returned HTML via HttpResponse(render(...)).
In today’s SPA (React/Vue/Next) + mobile combinations, the server returns only JSON and the client paints the screen. DRF fills that slot.
| Full-stack Django | Django + DRF | |
|---|---|---|
| Response | HTML (template) | JSON |
| Input | Form / ModelForm | Serializer |
| Auth | session + cookie | Token / JWT (+ session) |
| Client | Browser (server rendering) | SPA / mobile / external |
| Auto docs | Separate | drf-spectacular (#5) |
| Learning curve | Flat | Flat + extra DRF concepts |
The key point: the foundation — Django ORM, auth, admin, signals — comes through unchanged. DRF is the thin layer on top that swaps input/output to JSON.
Compared to FastAPI #
FastAPI sits in the same slot (JSON API). Comparison:
| Django + DRF | FastAPI | |
|---|---|---|
| Style | API layer on top of full-stack | Micro + types |
| ORM | Django ORM (mature) | External (SQLAlchemy, etc.) |
| Auth/permissions | Built-in (auth, permissions) | Hand-assembled |
| Admin | Strong built-in | None (external) |
| Migrations | makemigrations/migrate | Alembic (external) |
| Auto docs | drf-spectacular (separate) | Built-in |
| Async | Partial (4.0+) | Native |
| Data validation | Serializer | Pydantic |
| Best fit | Operations-focused, services with lots of admin/users/permissions | Microservices, data/ML APIs |
If you already have a monolithic service built with Django, DRF is the natural fit. If you start from scratch with just a small async API, FastAPI is lighter. This series goes deep on the DRF side.
Project setup #
We add DRF on top of the setup flow from Basics #2.
uv init blog-api --python 3.13
cd blog-api
uv add django djangorestframework
uv run django-admin startproject mysite .
uv run python manage.py startapp blogRegister INSTALLED_APPS
#
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework", # DRF itself
"blog", # our app
]DRF is added simply as a single app. It works right away with no middleware changes.
Domain models #
from django.conf import settings
from django.db import models
class Post(models.Model):
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
title = models.CharField(max_length=200)
body = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comments",
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]Same Model definition we saw in Basics #3. DRF’s core is using Django models as-is.
uv run python manage.py makemigrations
uv run python manage.py migrate
uv run python manage.py createsuperuserSerializer — DRF’s input/output schema #
A Serializer handles Python object ↔ JSON conversion + validation. The shape is similar to Django’s Form/ModelForm, with the key difference that it deals in JSON instead of HTML.
The most basic — serializers.Serializer
#
from rest_framework import serializers
class PostSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
title = serializers.CharField(max_length=200)
body = serializers.CharField()
published = serializers.BooleanField(default=False)
created_at = serializers.DateTimeField(read_only=True)
def create(self, validated_data):
return Post.objects.create(**validated_data)
def update(self, instance, validated_data):
for k, v in validated_data.items():
setattr(instance, k, v)
instance.save()
return instanceYou spell out each field directly. Fields with read_only=True go only in the output (server-generated id, created_at).
ModelSerializer — automatic inference from a model
#
Most of the time, you serialize a model directly. ModelSerializer does it in one line.
from rest_framework import serializers
from .models import Post, Comment
class PostSerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = ["id", "author", "title", "body", "published", "created_at", "updated_at"]
read_only_fields = ["id", "author", "created_at", "updated_at"]
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = ["id", "post", "author", "body", "created_at"]
read_only_fields = ["id", "author", "created_at"]What ModelSerializer does for you automatically:
- Infers serializer fields from model fields (
CharField→serializers.CharField) - Provides default
create()andupdate()implementations - Reflects model validations like
unique=True,max_lengthautomatically
You can also expose all fields with fields = "__all__", but listing them explicitly is safer — when a new field is added to the model, it could be unintentionally exposed.
What read_only_fields means
#
The reason we put author as read-only: to prevent clients from forging posts under someone else’s name. The pattern of stamping request.user as the author in the ViewSet’s perform_create appears in #2.
Validation — validate_<field>, validate, custom validator
#
There are three places to attach validation to a serializer.
1) Per-field — validate_<field>
#
class PostSerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = ["id", "title", "body", "published"]
def validate_title(self, value: str) -> str:
if value.strip().lower() in {"untitled", "test"}:
raise serializers.ValidationError("No meaningless titles")
return value.strip()A validate_<fieldname> method receives just that field’s value and validates it.
2) Model-wide — validate
#
def validate(self, attrs: dict) -> dict:
if attrs.get("published") and len(attrs.get("body", "")) < 100:
raise serializers.ValidationError(
{"body": "Published posts must be at least 100 characters."}
)
return attrsFor checking multiple fields together. attrs is the dict of all validated values.
3) Reusable validator #
def no_html_tags(value: str) -> None:
if "<script" in value.lower():
raise serializers.ValidationError("script tags are not allowed")
class PostSerializer(serializers.ModelSerializer):
body = serializers.CharField(validators=[no_html_tags])
...Functional validators are good for reuse across multiple serializers.
When validation fails, you automatically get a 400 Bad Request with a JSON response describing what’s wrong:
{
"title": ["No meaningless titles"],
"body": ["Published posts must be at least 100 characters."]
}View hierarchy — APIView → Generic → ViewSet #
DRF views are a hierarchy. The lower you go, the shorter the code; the higher, the more freedom.
APIView — most primitive, implement get/post/put yourself
↓
GenericAPIView — common attributes like queryset / serializer_class
↓ (+ mixins)
ListAPIView, RetrieveAPIView, CreateAPIView, ...
↓ (+ Router)
ViewSet → ModelViewSet — CRUD in one line1) APIView — most primitive #
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
class PostListAPIView(APIView):
def get(self, request):
posts = Post.objects.all()
serializer = PostSerializer(posts, many=True)
return Response(serializer.data)
def post(self, request):
serializer = PostSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(author=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)Resembles Django’s CBV — you implement get(), post() methods yourself. Most explicit but the same code repeats.
2) GenericAPIView + mixins — extracting common patterns #
from rest_framework import generics, mixins
class PostListCreateAPIView(
mixins.ListModelMixin,
mixins.CreateModelMixin,
generics.GenericAPIView,
):
queryset = Post.objects.all()
serializer_class = PostSerializer
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs)The two key lines are queryset + serializer_class. mixins provide methods like list(), create().
3) Generic shortcuts — ListCreateAPIView, etc.
#
from rest_framework import generics
class PostListCreateAPIView(generics.ListCreateAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
class PostDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializerDRF provides pre-built classes for combinations it sees often.
| Class | Methods |
|---|---|
ListAPIView | GET (list) |
CreateAPIView | POST |
ListCreateAPIView | GET + POST |
RetrieveAPIView | GET (detail) |
UpdateAPIView | PUT/PATCH |
DestroyAPIView | DELETE |
RetrieveUpdateDestroyAPIView | GET + PUT/PATCH + DELETE |
This pattern splits list and detail into two classes, with two separate URLs to match.
ViewSet — CRUD in one class #
ViewSet bundles list/retrieve/create/update/destroy in a single class, and Router generates URLs automatically. The standard DRF pattern.
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)These 4 lines build all of:
| Method | HTTP | URL | Action |
|---|---|---|---|
list | GET | /posts/ | List |
retrieve | GET | /posts/{id}/ | Detail |
create | POST | /posts/ | Create |
update | PUT | /posts/{id}/ | Full update |
partial_update | PATCH | /posts/{id}/ | Partial update |
destroy | DELETE | /posts/{id}/ | Delete |
perform_create() is the hook for adding extra data at save time. Above, it stamps author with the current request user.
Other commonly used hooks #
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
def get_queryset(self):
# Decide queryset dynamically — e.g., my posts only
qs = super().get_queryset()
if self.action == "list" and self.request.user.is_authenticated:
return qs.filter(author=self.request.user)
return qs.filter(published=True)
def get_serializer_class(self):
# Different serializer per action
if self.action == "list":
return PostListSerializer
return PostSerializerget_queryset()— dynamic queryset per action/user/permissionget_serializer_class()— split light serialization for list and detailed serialization for retrieve/create
@action — extra endpoints
#
When you want extra actions beyond standard CRUD.
from rest_framework.decorators import action
from rest_framework.response import Response
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
"""POST /posts/{id}/publish/ — publish the post."""
post = self.get_object()
post.published = True
post.save()
return Response({"published": True})
@action(detail=False, methods=["get"])
def drafts(self, request):
"""GET /posts/drafts/ — my drafts."""
qs = self.get_queryset().filter(author=request.user, published=False)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)detail=True— instance-level (/posts/{id}/publish/)detail=False— collection-level (/posts/drafts/)
Router — automatic URL generation #
A ViewSet on its own can’t be registered to a URL — you have to specify which method maps to which HTTP verb. Router does that automatically.
from rest_framework.routers import DefaultRouter
from .views import PostViewSet, CommentViewSet
router = DefaultRouter()
router.register(r"posts", PostViewSet, basename="post")
router.register(r"comments", CommentViewSet, basename="comment")
urlpatterns = router.urlsfrom django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("blog.urls")),
]That’s it. URLs that DefaultRouter generates:
GET /api/posts/ → list
POST /api/posts/ → create
GET /api/posts/{pk}/ → retrieve
PUT /api/posts/{pk}/ → update
PATCH /api/posts/{pk}/ → partial_update
DELETE /api/posts/{pk}/ → destroy
POST /api/posts/{pk}/publish/ → @action(detail=True) publish
GET /api/posts/drafts/ → @action(detail=False) drafts
GET /api/ → API root (DefaultRouter only)DefaultRouter also automatically builds an API index at the /api/ root. SimpleRouter only adds the routes, no index.
Browsable API — DRF’s secret weapon #
Just open /api/posts/ in a browser. DRF automatically serves an HTML interface — submit POST via a form, view the JSON response, and even toggle authentication state in the UI.
Very useful in early API development. To turn it off in production, remove BrowsableAPIRenderer from DEFAULT_RENDERER_CLASSES.
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
# "rest_framework.renderers.BrowsableAPIRenderer", # dev only
],
}First result — all on one screen #
from rest_framework import serializers
from .models import Post, Comment
class PostSerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = ["id", "author", "title", "body", "published", "created_at", "updated_at"]
read_only_fields = ["id", "author", "created_at", "updated_at"]
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = ["id", "post", "author", "body", "created_at"]
read_only_fields = ["id", "author", "created_at"]from rest_framework import viewsets
from .models import Post, Comment
from .serializers import PostSerializer, CommentSerializer
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)
class CommentViewSet(viewsets.ModelViewSet):
queryset = Comment.objects.all()
serializer_class = CommentSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)from rest_framework.routers import DefaultRouter
from .views import PostViewSet, CommentViewSet
router = DefaultRouter()
router.register(r"posts", PostViewSet)
router.register(r"comments", CommentViewSet)
urlpatterns = router.urlsuv run python manage.py runserver
# → http://127.0.0.1:8000/api/Open /api/posts/ in a browser to get the Browsable API, where you can create posts via the form. The next posts stack auth/filtering/async/docs/deployment one layer at a time on top of this.
Recap #
What this post nailed down:
- Where DRF fits — JSON API layer on top of Django (ORM/auth/admin unchanged)
- Django + DRF vs FastAPI comparison — different best fits
- Add
rest_frameworkas a single line inINSTALLED_APPS - Serializer — Python ↔ JSON + validation
serializers.Serializer(manual),ModelSerializer(automatic)- Protect server-determined fields with
read_only_fields
- Three places for validation —
validate_<field>,validate, functional validator - View hierarchy —
APIView→GenericAPIView+ mixins → shortcut →ViewSet ModelViewSet— CRUD in one class, stamp author withperform_create@actionfor extra endpoints (publish,drafts)- Router —
DefaultRouter().register()for automatic URL generation - Browsable API — HTML interface for direct testing in the browser
The model definitions from Basics #3 Models and ORM carry over unchanged, and the User model from Basics #7 Admin and Auth connects via the author ForeignKey.
The next post (#2 Authentication/Permissions) covers who can write and edit those posts — Token/JWT authentication and permission classes like IsAuthenticated/IsOwner.