Django DRF #1: Getting Started — Serializer, ViewSet, Router

10 min read

This is the series where everything from Django BasicsIntermediateAdvanced 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 APIPost, 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 DjangoDjango + DRF
ResponseHTML (template)JSON
InputForm / ModelFormSerializer
Authsession + cookieToken / JWT (+ session)
ClientBrowser (server rendering)SPA / mobile / external
Auto docsSeparatedrf-spectacular (#5)
Learning curveFlatFlat + 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 + DRFFastAPI
StyleAPI layer on top of full-stackMicro + types
ORMDjango ORM (mature)External (SQLAlchemy, etc.)
Auth/permissionsBuilt-in (auth, permissions)Hand-assembled
AdminStrong built-inNone (external)
Migrationsmakemigrations/migrateAlembic (external)
Auto docsdrf-spectacular (separate)Built-in
AsyncPartial (4.0+)Native
Data validationSerializerPydantic
Best fitOperations-focused, services with lots of admin/users/permissionsMicroservices, 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.

Create the project
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 blog

Register INSTALLED_APPS #

mysite/settings.py
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 #

blog/models.py
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.

Migrations
uv run python manage.py makemigrations
uv run python manage.py migrate
uv run python manage.py createsuperuser

Serializer — 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 #

blog/serializers.py — basic form
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 instance

You 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.

blog/serializers.py — ModelSerializer
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 (CharFieldserializers.CharField)
  • Provides default create() and update() implementations
  • Reflects model validations like unique=True, max_length automatically

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> #

Per-field validation
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 #

Multiple fields at once
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 attrs

For checking multiple fields together. attrs is the dict of all validated values.

3) Reusable validator #

Reusable across the whole blog
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:

Validation failure response
{
  "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.

Hierarchy
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 line

1) APIView — most primitive #

blog/views.py — APIView
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 #

generic + mixins
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. #

Even shorter
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 = PostSerializer

DRF provides pre-built classes for combinations it sees often.

ClassMethods
ListAPIViewGET (list)
CreateAPIViewPOST
ListCreateAPIViewGET + POST
RetrieveAPIViewGET (detail)
UpdateAPIViewPUT/PATCH
DestroyAPIViewDELETE
RetrieveUpdateDestroyAPIViewGET + 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.

blog/views.py — ModelViewSet
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:

MethodHTTPURLAction
listGET/posts/List
retrieveGET/posts/{id}/Detail
createPOST/posts/Create
updatePUT/posts/{id}/Full update
partial_updatePATCH/posts/{id}/Partial update
destroyDELETE/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 #

ViewSet 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 PostSerializer
  • get_queryset() — dynamic queryset per action/user/permission
  • get_serializer_class() — split light serialization for list and detailed serialization for retrieve/create

@action — extra endpoints #

When you want extra actions beyond standard CRUD.

@action decorator
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.

blog/urls.py
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.urls
mysite/urls.py
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("blog.urls")),
]

That’s it. URLs that DefaultRouter generates:

Auto-generated URLs
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.

settings.py — JSON only in production
REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
        # "rest_framework.renderers.BrowsableAPIRenderer",   # dev only
    ],
}

First result — all on one screen #

blog/serializers.py
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"]
blog/views.py
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)
blog/urls.py
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.urls
Run
uv 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_framework as a single line in INSTALLED_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 hierarchyAPIViewGenericAPIView + mixins → shortcut → ViewSet
  • ModelViewSet — CRUD in one class, stamp author with perform_create
  • @action for extra endpoints (publish, drafts)
  • RouterDefaultRouter().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.

X